Merge origin/main into fix/permanent-daemon-watch

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-28 18:15:03 +01:00
commit 545c1bd519
36 files changed
+4326 -530

No files matched your search

+5 -2
View File
@@ -694,7 +694,7 @@ overwrite user-modified agents.
| Agent | Activation | MCP config | Durable context / augmentation |
|-------|------------|------------|--------------------------------|
| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, and post-`Read` coverage |
| Claude Code | Detected | `~/.claude.json` | Skill + three exact-tool graph agents; `SessionStart`, `SubagentStart`, non-blocking `PreToolUse` for `Grep`/`Glob`/`Bash`, post-`Read` coverage, and a drift note after repeated searches without a graph call |
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches |
| Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` |
| Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill |
@@ -755,7 +755,10 @@ the requested tool call. Codex CLI's `PreToolUse` on `Bash` gives its shell
searches the same context. GitHub Copilot CLI and VS Code's Copilot runtime get
it after the search: their `preToolUse` can only decide permissions, so a
`PostToolUse` entry for `Bash`, `Grep` and `Glob` appends the graph symbols to
the tool result.
the tool result. In Claude Code, once a session has run three searches with no
codebase-memory-mcp tool call, the next augmentation adds one line saying so;
the next note comes after six searches, then twelve. Claude's `PostToolUse`
entry also matches the graph tools, and a graph tool call resets the count.
Claude Code, Codex CLI, Qwen Code, GitHub Copilot CLI, and VS Code's Copilot
runtime receive paired session/subagent context where the vendor exposes a
+37 -12
View File
@@ -1663,27 +1663,39 @@ typedef struct {
TSNode node;
const char *prefix;
} yaml_walk_frame_t;
#define YAML_WALK_STACK_CAP CBM_SZ_256
/* On-stack first chunk of the key-path walk; wider mappings spill to the heap
* through cbm_walk_stack_reserve instead of dropping frames (#2388). */
#define YAML_WALK_STACK_INLINE CBM_SZ_256
/* Push block_mapping children of a block_node/block_mapping value onto the walk stack. */
static void push_yaml_block_children(TSNode val, const char *path, yaml_walk_frame_t *stack,
int *top) {
/* Push block_mapping children of a block_node/block_mapping value onto the walk
* stack, growing it as needed. False when the stack could not grow (logged by
* cbm_walk_stack_reserve as extract.walk_stack_alloc_failed). */
static bool push_yaml_block_children(TSNode val, const char *path, yaml_walk_frame_t **stack,
int *cap, int *top, const yaml_walk_frame_t *inline_buf) {
uint32_t vnc = ts_node_named_child_count(val);
for (int vi = (int)vnc - SKIP_ONE; vi >= 0 && *top < YAML_WALK_STACK_CAP; vi--) {
if (!cbm_walk_stack_reserve((void **)stack, cap, *top + (int)vnc, sizeof(yaml_walk_frame_t),
inline_buf, "yaml_key_paths")) {
return false;
}
for (int vi = (int)vnc - SKIP_ONE; vi >= 0; vi--) {
TSNode vc = ts_node_named_child(val, (uint32_t)vi);
const char *vctype = ts_node_type(vc);
if (strcmp(vctype, "block_mapping") == 0 || strcmp(vctype, "block_mapping_pair") == 0) {
stack[(*top)++] = (yaml_walk_frame_t){vc, path};
(*stack)[(*top)++] = (yaml_walk_frame_t){vc, path};
}
}
return true;
}
static void walk_yaml_mapping(CBMExtractCtx *ctx, TSNode root, const char *root_prefix) {
yaml_walk_frame_t stack[YAML_WALK_STACK_CAP];
yaml_walk_frame_t inline_stack[YAML_WALK_STACK_INLINE];
yaml_walk_frame_t *stack = inline_stack;
int cap = YAML_WALK_STACK_INLINE;
int top = 0;
stack[top++] = (yaml_walk_frame_t){root, root_prefix};
bool grown = true;
while (top > 0) {
while (grown && top > 0) {
yaml_walk_frame_t frame = stack[--top];
TSNode node = frame.node;
const char *prefix = frame.prefix;
@@ -1709,12 +1721,16 @@ static void walk_yaml_mapping(CBMExtractCtx *ctx, TSNode root, const char *root_
}
const char *vk = ts_node_type(val);
if (strcmp(vk, "block_node") == 0 || strcmp(vk, "block_mapping") == 0) {
push_yaml_block_children(val, path, stack, &top);
grown = push_yaml_block_children(val, path, &stack, &cap, &top, inline_stack);
if (!grown) {
break;
}
continue;
}
emit_yaml_leaf_value(ctx, val, path);
}
}
cbm_walk_stack_release(stack, inline_stack);
}
/* ── Infrastructure binding extraction ─────────────────────────────
@@ -1873,9 +1889,13 @@ static void scan_mapping_for_bindings(CBMExtractCtx *ctx, TSNode mapping) {
emit_infra_bindings(ctx, sources, source_keys, n_sources, targets, n_targets);
}
#define INFRA_SCAN_STACK_CAP CBM_SZ_512
/* On-stack first chunk of the binding scan; wider subtrees spill to the heap
* through cbm_walk_stack_reserve instead of dropping nodes (#2388). */
#define INFRA_SCAN_STACK_INLINE CBM_SZ_512
static void scan_yaml_for_infra_bindings(CBMExtractCtx *ctx, TSNode root) {
TSNode stack[INFRA_SCAN_STACK_CAP];
TSNode inline_stack[INFRA_SCAN_STACK_INLINE];
TSNode *stack = inline_stack;
int cap = INFRA_SCAN_STACK_INLINE;
int top = 0;
stack[top++] = root;
while (top > 0) {
@@ -1884,10 +1904,15 @@ static void scan_yaml_for_infra_bindings(CBMExtractCtx *ctx, TSNode root) {
scan_mapping_for_bindings(ctx, node);
}
uint32_t nc = ts_node_named_child_count(node);
for (int i = (int)nc - SKIP_ONE; i >= 0 && top < INFRA_SCAN_STACK_CAP; i--) {
if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc, sizeof(TSNode),
inline_stack, "yaml_infra_bindings")) {
break;
}
for (int i = (int)nc - SKIP_ONE; i >= 0; i--) {
stack[top++] = ts_node_named_child(node, (uint32_t)i);
}
}
cbm_walk_stack_release(stack, inline_stack);
}
/* ── HCL infrastructure binding extraction ───────────────────────────
+13 -1
View File
@@ -401,10 +401,22 @@ static bool is_interpolated_string(TSNode node) {
return false;
}
/* A Python string whose opening token is more than one quote -- a prefix
* (r"", b"", u"", an f"" with no field) or a triple quote -- cannot go down
* the literal path: that strips exactly one quote from each end, so the
* prefix and the extra quotes stayed in the URL and it never matched a route
* (#523). Folding reads the content pieces instead. JS/TS strings have no
* string_start, so this is Python only. */
static bool is_wrapped_string(TSNode node) {
TSNode start = ts_node_child(node, 0);
return !ts_node_is_null(start) && strcmp(ts_node_type(start), "string_start") == 0 &&
ts_node_end_byte(start) - ts_node_start_byte(start) > SKIP_ONE;
}
static bool is_foldable_arg(TSNode arg) {
const char *k = ts_node_type(arg);
if (strcmp(k, "string") == 0) {
return is_interpolated_string(arg);
return is_interpolated_string(arg) || is_wrapped_string(arg);
}
return strcmp(k, "template_string") == 0 || strcmp(k, "binary_expression") == 0 ||
strcmp(k, "binary_operator") == 0 || strcmp(k, "identifier") == 0 ||
+1 -1
View File
@@ -13,7 +13,7 @@ internal/cbm/lsp/perl_lsp.c 21
internal/cbm/macro_table.c 1
internal/cbm/service_patterns.c 2
internal/cbm/sqlite_writer.c 74
src/cli/activation_transaction.c 80
src/cli/activation_transaction.c 78
src/cli/agent_clients.c 73
src/cli/agent_profiles.c 7
src/cli/cli.c 166
+6 -4
View File
@@ -1697,7 +1697,8 @@ echo "OK 8c-i: Claude exact-tool graph subagent"
# 8d: Claude Code hooks keep search augmentation and read-coverage reporting
# separate: PreToolUse matches exactly Grep|Glob|Bash, while PostToolUse matches
# exactly Read. Neither hook may grow a Search or catch-all matcher.
# exactly Read plus the graph tools, whose calls reset the drift note. Neither
# hook may grow a Search or catch-all matcher.
if ! cat "$FAKE_HOME/.claude/settings.json" 2>/dev/null | python3 -c "
import json, sys
d = json.load(sys.stdin)
@@ -1705,14 +1706,15 @@ all_hooks = d.get('hooks', {})
pre = all_hooks.get('PreToolUse', [])
post = all_hooks.get('PostToolUse', [])
ok = (any(h.get('matcher') == 'Grep|Glob|Bash' for h in pre) and
any(h.get('matcher') == 'Read' for h in post))
bad = any('Search' in str(h.get('matcher', '')) for h in pre + post)
any(h.get('matcher') == 'Read|mcp__.*codebase-memory-mcp__.*' for h in post))
bad = any('Search' in str(h.get('matcher', '')) or h.get('matcher', '') in ('', '*')
for h in pre + post)
sys.exit(0 if (ok and not bad) else 1)
" 2>/dev/null; then
echo "FAIL 8d: Claude search/read hook matchers are not exact"
exit 1
fi
echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read"
echo "OK 8d: Claude Code PreToolUse Grep|Glob|Bash + PostToolUse Read and graph tools"
# 8e: Claude Code shim script — must be non-blocking augmenter, not a gate.
# #929: Windows installs a .cmd script (extensionless bash shims triggered the
+89 -19
View File
@@ -899,6 +899,61 @@ static bool activation_posix_acl_empty(int descriptor) {
return cbm_macos_extended_acl_fd_is_empty(descriptor);
}
#ifdef __linux__
/* Root of the per-user home tree and the account trusted to own it. Root and
* "/home" in production; the test seam substitutes a fixture tree and the test
* account, because the real layout needs root-owned entries to reproduce. */
static const char *g_activation_home_root = "/home";
static uid_t g_activation_home_owner = 0;
#ifdef CBM_ENABLE_TEST_SEAMS
void cbm_activation_transaction_set_home_root_for_testing(const char *home_root,
unsigned long owner_uid) {
g_activation_home_root = home_root ? home_root : "/home";
g_activation_home_owner = home_root ? (uid_t)owner_uid : 0;
}
#endif
#endif
#if defined(__APPLE__) || defined(__linux__)
/* Map a trusted symlink `link` (already lstat'd) onto its resolved target and
* append `rest`. The link and the target must both belong to `owner`; with
* accept_own_target the target may instead belong to the current account.
* Returns NULL when either check fails or the mapped path does not fit. */
static char *activation_alias_map(const char *link, const struct stat *link_status, uid_t owner,
bool accept_own_target, const char *rest) {
char resolved[4096];
struct stat resolved_status;
if (link_status->st_uid != owner || !realpath(link, resolved) ||
lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode)) {
return NULL;
}
if (resolved_status.st_uid != owner &&
!(accept_own_target && resolved_status.st_uid == geteuid())) {
return NULL;
}
char mapped[4096];
int written = snprintf(mapped, sizeof(mapped), "%s%s", resolved, rest);
if (written <= 0 || (size_t)written >= sizeof(mapped)) {
return NULL;
}
return activation_string_copy(mapped);
}
#ifdef CBM_ENABLE_TEST_SEAMS
char *cbm_activation_transaction_map_alias_for_testing(const char *link,
unsigned long link_owner_uid,
unsigned long trusted_owner_uid,
bool accept_own_target, const char *rest) {
/* Only link ownership is synthetic; the shared production helper resolves
* and checks the real target so distinct-owner cases need no chown/root. */
const struct stat link_status = {.st_uid = (uid_t)link_owner_uid};
return activation_alias_map(link, &link_status, (uid_t)trusted_owner_uid, accept_own_target,
rest);
}
#endif
#endif
static char *activation_posix_walk_path(const char *directory) {
#if defined(__APPLE__) || defined(__linux__)
/* macOS and immutable Linux layouts can expose writable trees through
@@ -906,7 +961,7 @@ static char *activation_posix_walk_path(const char *directory) {
* Resolve only these trusted system aliases; arbitrary user symlinks must
* still fail the O_NOFOLLOW walk below. */
#ifdef __linux__
static const char *const aliases[] = {"/tmp", "/var", "/home"};
const char *const aliases[] = {"/tmp", "/var", g_activation_home_root};
#else
static const char *const aliases[] = {"/tmp", "/var"};
#endif
@@ -917,33 +972,48 @@ static char *activation_posix_walk_path(const char *directory) {
(directory[alias_length] != '\0' && directory[alias_length] != '/')) {
continue;
}
uid_t owner = 0;
#ifdef __linux__
bool is_home = alias == g_activation_home_root;
if (is_home) {
owner = g_activation_home_owner;
}
#endif
struct stat alias_status;
char resolved[4096];
if (lstat(alias, &alias_status) != 0) {
continue;
}
if (!S_ISLNK(alias_status.st_mode)) {
if (S_ISLNK(alias_status.st_mode)) {
return activation_alias_map(alias, &alias_status, owner, false,
directory + alias_length);
}
#ifdef __linux__
/* Managed Linux hosts often keep /home a real directory and point the
* per-user entry elsewhere (/home/alice -> /local/home/alice, #2306).
* Trust that entry only inside a root-owned /home nobody else can
* write, and only when root owns the entry itself. Unlike the whole-
* alias case above, the target may belong to the current account:
* that is the normal shape of a relocated home, and an account can
* already write its own home, so it gains nothing it did not have. */
const char *name = directory + alias_length;
size_t name_length = *name == '/' ? strcspn(name + 1, "/") : 0;
if (!is_home || !S_ISDIR(alias_status.st_mode) || alias_status.st_uid != owner ||
(alias_status.st_mode & 0022) != 0 || name_length == 0) {
continue;
}
if (alias_status.st_uid != 0 || !realpath(alias, resolved)) {
char entry[4096];
size_t entry_length = alias_length + 1U + name_length;
if (entry_length >= sizeof(entry)) {
return NULL;
}
struct stat resolved_status;
if (lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) ||
resolved_status.st_uid != 0) {
return NULL;
memcpy(entry, directory, entry_length);
entry[entry_length] = '\0';
struct stat entry_status;
if (lstat(entry, &entry_status) == 0 && S_ISLNK(entry_status.st_mode)) {
return activation_alias_map(entry, &entry_status, owner, true,
directory + entry_length);
}
size_t needed = strlen(resolved) + strlen(directory + alias_length) + 1U;
char *mapped = malloc(needed);
if (!mapped) {
return NULL;
}
int written = snprintf(mapped, needed, "%s%s", resolved, directory + alias_length);
if (written <= 0 || (size_t)written >= needed) {
free(mapped);
return NULL;
}
return mapped;
#endif
}
#endif
return activation_string_copy(directory);
+15
View File
@@ -104,6 +104,21 @@ const char *cbm_activation_transaction_refusal_note(void);
#ifdef CBM_ENABLE_TEST_SEAMS
void cbm_activation_transaction_note_refusal_for_testing(const char *predicate,
unsigned long os_error);
/* Linux only: stand home_root in for /home and owner_uid in for root when
* deciding whether /home or a /home/<name> entry is a trusted alias. NULL
* restores the production values. Inert elsewhere. */
void cbm_activation_transaction_set_home_root_for_testing(const char *home_root,
unsigned long owner_uid);
#if defined(__APPLE__) || defined(__linux__)
/* Exercise the shared alias checks with synthetic link ownership and a real
* target. The caller frees the mapped path; NULL means the alias was refused. */
char *cbm_activation_transaction_map_alias_for_testing(const char *link,
unsigned long link_owner_uid,
unsigned long trusted_owner_uid,
bool accept_own_target, const char *rest);
#endif
void cbm_activation_transaction_write_failures_set_for_testing(unsigned int count, int os_error);
#endif
+12 -2
View File
@@ -4661,7 +4661,10 @@ static int cbm_remove_grok_mcp_owned(const char *binary_path, const char *config
/* Search augmentation runs before Grep/Glob/Bash; exact coverage context runs after
* Read. Both adapters are context-only and fail open. */
#define CMM_HOOK_SEARCH_MATCHER "Grep|Glob|Bash"
#define CMM_HOOK_READ_MATCHER "Read"
/* The PostToolUse entry also matches the graph tools (mcp__<server>__<tool>,
* or mcp__plugin_<plugin>_<server>__<tool>), whose calls reset the drift
* reminder's search count (#509). */
#define CMM_HOOK_READ_MATCHER "Read|mcp__.*codebase-memory-mcp__.*"
/* Basename only; the full command path is resolved at install time via
* cbm_resolve_hook_command so $CLAUDE_CONFIG_DIR is honored. */
#ifdef _WIN32
@@ -4682,6 +4685,11 @@ static const char *const cmm_claude_exec_args[] = {"hook-augment"};
/* Old matcher values from previous versions — recognized during upgrade so
* upsert/remove can clean them up before inserting the current matcher.
* Per-agent lists (no shared global): each caller passes its own. */
/* Installs up to v0.12 matched only Read in PostToolUse. */
static const char *const cmm_claude_old_read_matchers[] = {
"Read",
NULL,
};
static const char *const cmm_claude_old_matchers[] = {
"Grep|Glob|Read|Search",
"Grep|Glob|Read",
@@ -5459,7 +5467,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const
if (cbm_remove_previous_claude_exec_hook(settings_path, "PreToolUse", CMM_HOOK_SEARCH_MATCHER,
cmm_claude_old_matchers, binary_path) != CLI_OK ||
cbm_remove_previous_claude_exec_hook(settings_path, "PostToolUse", CMM_HOOK_READ_MATCHER,
NULL, binary_path) != CLI_OK) {
cmm_claude_old_read_matchers, binary_path) != CLI_OK) {
return CLI_ERR;
}
int search_result = upsert_hooks_json((hooks_upsert_args_t){
@@ -5478,6 +5486,7 @@ static int cbm_upsert_claude_hooks_with_binary(const char *settings_path, const
.settings_path = settings_path,
.hook_event = "PostToolUse",
.matcher_str = CMM_HOOK_READ_MATCHER,
.old_matchers = cmm_claude_old_read_matchers,
.command_str = command,
.command_args = command_args,
.command_arg_count = command_arg_count,
@@ -5532,6 +5541,7 @@ static int cbm_remove_claude_hooks_with_binary(const char *settings_path, const
.settings_path = settings_path,
.hook_event = "PostToolUse",
.matcher_str = CMM_HOOK_READ_MATCHER,
.old_matchers = cmm_claude_old_read_matchers,
.old_commands = old_commands,
.match_command_exact = command,
.match_args = command_args,
+19 -21
View File
@@ -148,38 +148,36 @@ static void append_typebox_type(adapter_sb_t *sb, yyjson_val *val, const char *d
if (type_str && strcmp(type_str, "string") == 0 && enum_val && yyjson_is_arr(enum_val) &&
yyjson_arr_size(enum_val) > 0) {
sb_append(sb, "Type.Union([");
size_t enum_idx = 0;
size_t enum_max = yyjson_arr_size(enum_val);
bool first_enum = true;
size_t valid_count = 0;
for (enum_idx = 0; enum_idx < enum_max; enum_idx++) {
yyjson_val *enum_item = yyjson_arr_get(enum_val, enum_idx);
const char *enum_str = yyjson_get_str(enum_item);
if (!enum_str) {
continue;
if (yyjson_get_str(yyjson_arr_get(enum_val, enum_idx))) {
valid_count++;
}
if (!first_enum) {
sb_append(sb, ", ");
}
first_enum = false;
sb_append(sb, "Type.Literal(");
sb_append_js_string(sb, enum_str);
sb_append(sb, ")");
}
if (first_enum) {
sb_append(sb, "Type.String()");
} else {
if (valid_count > 0) {
sb_append(sb, "Type.String({ enum: [");
bool first_enum = true;
for (enum_idx = 0; enum_idx < enum_max; enum_idx++) {
const char *enum_str = yyjson_get_str(yyjson_arr_get(enum_val, enum_idx));
if (!enum_str) {
continue;
}
if (!first_enum) {
sb_append(sb, ", ");
}
first_enum = false;
sb_append_js_string(sb, enum_str);
}
sb_append(sb, "]");
if (include_description && description) {
sb_append(sb, ", { description: ");
sb_append(sb, ", description: ");
sb_append_js_string(sb, description);
sb_append(sb, " }");
}
sb_append(sb, ")");
sb_append(sb, " })");
return;
}
sb_append(sb, ")");
return;
}
const char *typebox_name = "Any";
+142 -3
View File
@@ -26,6 +26,8 @@
#include "yyjson/yyjson.h"
#include <ctype.h>
#include <limits.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
@@ -1414,6 +1416,129 @@ static void ha_tool_call_free(ha_tool_call_t *call) {
call->input = NULL;
}
/* Drift reminder (#509). A Claude Code session that keeps searching with Grep,
* Glob or Bash and makes no codebase-memory-mcp tool call gets one factual line
* on its next augmentation once HA_DRIFT_FIRST searches have run; the threshold
* then doubles (3, 6, 12, ...), and a graph tool call resets the session. The
* managed PostToolUse hook matches the graph tools for that reset. The state
* lives in the process that runs hook requests, which is the daemon, keyed by
* the payload's session_id in a bounded table that evicts the least recently
* used session. Codex sends the same event payload, with a turn_id, but has no
* reset hook, so its sessions are not tracked. */
enum {
HA_DRIFT_SESSIONS = 64,
HA_DRIFT_FIRST = 3,
HA_DRIFT_SESSION_MAX = 128,
};
typedef struct {
char session[HA_DRIFT_SESSION_MAX + 1];
unsigned searches;
unsigned threshold;
uint64_t used;
} ha_drift_slot_t;
static ha_drift_slot_t g_ha_drift[HA_DRIFT_SESSIONS];
static uint64_t g_ha_drift_clock;
static atomic_flag g_ha_drift_spin = ATOMIC_FLAG_INIT;
static void ha_drift_lock(void) {
while (atomic_flag_test_and_set_explicit(&g_ha_drift_spin, memory_order_acquire)) {}
}
static void ha_drift_unlock(void) {
atomic_flag_clear_explicit(&g_ha_drift_spin, memory_order_release);
}
static const char *ha_drift_session(yyjson_val *root, ha_lifecycle_dialect_t dialect) {
if (dialect != HA_DIALECT_EVENT || !root || yyjson_obj_get(root, "turn_id")) {
return NULL;
}
const char *session = ha_obj_str(root, "session_id");
size_t length = session ? strlen(session) : 0U;
if (length == 0U || length > HA_DRIFT_SESSION_MAX) {
return NULL;
}
for (size_t i = 0U; i < length; i++) {
unsigned char ch = (unsigned char)session[i];
if (!isalnum(ch) && ch != '-' && ch != '_' && ch != '.') {
return NULL;
}
}
return session;
}
/* Claude names MCP tools mcp__<server>__<tool>, and a plugin's servers
* mcp__plugin_<plugin>_<server>__<tool>. */
static bool ha_is_graph_tool(const char *tool) {
return tool && strncmp(tool, "mcp__", 5) == 0 && strstr(tool, "codebase-memory-mcp__");
}
/* The caller holds the lock. An unknown session takes an empty slot, or else
* the least recently used one. */
static ha_drift_slot_t *ha_drift_find(const char *session, bool create) {
ha_drift_slot_t *victim = NULL;
for (size_t i = 0U; i < HA_DRIFT_SESSIONS; i++) {
ha_drift_slot_t *slot = &g_ha_drift[i];
if (slot->session[0] && strcmp(slot->session, session) == 0) {
slot->used = ++g_ha_drift_clock;
return slot;
}
if (!victim || (victim->session[0] && (!slot->session[0] || slot->used < victim->used))) {
victim = slot;
}
}
if (!create) {
return NULL;
}
snprintf(victim->session, sizeof(victim->session), "%s", session);
victim->searches = 0U;
victim->threshold = HA_DRIFT_FIRST;
victim->used = ++g_ha_drift_clock;
return victim;
}
static void ha_drift_reset(const char *session) {
ha_drift_lock();
ha_drift_slot_t *slot = ha_drift_find(session, false);
if (slot) {
memset(slot, 0, sizeof(*slot));
}
ha_drift_unlock();
}
/* Counts one search. The reminder is due when the count has reached the
* threshold and this search has an augmentation to carry it; delivering it
* raises the threshold past the count by doubling. */
static bool ha_drift_note_search(const char *session, bool augmented, unsigned *searches) {
ha_drift_lock();
ha_drift_slot_t *slot = ha_drift_find(session, true);
if (slot->searches < UINT_MAX) {
slot->searches++;
}
bool due = augmented && slot->searches >= slot->threshold;
while (due && slot->threshold <= slot->searches && slot->threshold <= UINT_MAX / 2U) {
slot->threshold *= 2U;
}
*searches = slot->searches;
ha_drift_unlock();
return due;
}
/* Writes the context followed by the drift line into out. Returns false when
* they do not fit, and the caller then sends the context alone. The context is
* at most 4096 bytes plus the follow-up line, so HA_DRIFT_CONTEXT_CAP fits it. */
enum { HA_DRIFT_CONTEXT_CAP = 5120 };
static bool ha_drift_context(char *out, size_t out_size, const char *context, unsigned searches) {
int written = snprintf(out, out_size,
"%s\n[codebase-memory] This session has run %u searches since its last "
"codebase-memory-mcp tool call. Questions about callers, callees, call "
"paths and definitions are one graph call each.",
context, searches);
return written > 0 && (size_t)written < out_size;
}
static bool ha_tool_event_supported(ha_lifecycle_dialect_t dialect, const char *event,
const char *tool, bool *coverage) {
if (coverage) {
@@ -1752,6 +1877,13 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha
ha_tool_call_read(root, forced_event, dialect, &call);
const char *event = call.event;
const char *tool = call.tool;
const char *session = ha_drift_session(root, dialect);
if (session && event && strcmp(event, "PostToolUse") == 0 && ha_is_graph_tool(tool)) {
ha_drift_reset(session);
ha_tool_call_free(&call);
yyjson_doc_free(doc);
return NULL;
}
bool coverage = false;
if (!ha_tool_event_supported(dialect, event, tool, &coverage)) {
ha_tool_call_free(&call);
@@ -1809,10 +1941,17 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const cha
}
char *ctx = ha_resolve_and_query(srv, cwd, token);
unsigned searches = 0U;
char drift_context[HA_DRIFT_CONTEXT_CAP];
const char *text = ctx;
if (session && ha_drift_note_search(session, ctx != NULL, &searches) &&
ha_drift_context(drift_context, sizeof(drift_context), ctx, searches)) {
text = drift_context;
}
char *output = NULL;
if (ctx) {
output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(ctx)
: ha_build_event_json(event, ctx);
if (text) {
output = dialect == HA_DIALECT_COPILOT ? ha_build_copilot_json(text)
: ha_build_event_json(event, text);
}
free(ctx);
yyjson_doc_free(doc);
+17 -7
View File
@@ -75,13 +75,23 @@ static const char *ALWAYS_SKIP_DIR_SUFFIXES[] = {
"storage/framework/views", NULL};
static const char *FAST_SKIP_DIRS[] = {
"generated", "gen", "auto-generated", "fixtures", "testdata", "test_data",
"__tests__", "__mocks__", "__snapshots__", "__fixtures__", "__test__", "docs",
"doc", "documentation", "examples", "example", "samples", "sample",
"assets", "static", "public", "media", "third_party", "thirdparty",
"3rdparty", "external", "migrations", "seeds", "e2e", "integration",
"locale", "locales", "i18n", "l10n", "scripts", "tools",
"hack", "bin", "build", "out", NULL};
/* Generated */
"generated", "gen", "auto-generated",
/* Tests */
"fixtures", "testdata", "test_data", "__tests__", "__mocks__", "__snapshots__", "__fixtures__",
"__test__",
/* Docs and samples */
"docs", "doc", "documentation", "examples", "example", "samples", "sample",
/* Assets */
"assets", "static", "public", "media",
/* Third-party */
"third_party", "thirdparty", "3rdparty",
/* Data and integration tests */
"migrations", "seeds", "e2e", "integration",
/* Localization */
"locale", "locales", "i18n", "l10n",
/* Tooling and build output */
"scripts", "tools", "hack", "bin", "build", "out", NULL};
/* ── Ignored suffixes ───────────────────────────────── */
+3 -2
View File
@@ -66,8 +66,9 @@ typedef struct {
static mem_class_stats_t g_classes[CBM_MEM_CLASS_COUNT];
static const char *const g_class_names[CBM_MEM_CLASS_COUNT] = {
"other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index", "extract", "arena",
"ts_tree", "semantic", "dump", "store", "hash_table", "dyn_array", "graph_cache",
"other", "gbuf_node", "gbuf_edge", "gbuf_string", "gbuf_index",
"extract", "arena", "ts_tree", "semantic", "dump",
"store", "hash_table", "dyn_array", "resolve", "graph_cache",
};
const char *cbm_mem_class_name(cbm_mem_class_t cls) {
+1
View File
@@ -66,6 +66,7 @@ typedef enum {
CBM_MEM_CLASS_STORE, /* SQLite (bound mem methods) + store batches and row buffers */
CBM_MEM_CLASS_HASH_TABLE, /* CBMHashTable buckets/entries not claimed by an owner class */
CBM_MEM_CLASS_DYN_ARRAY, /* CBM_DYN_ARRAY item storage (every cbm_da_* user) */
CBM_MEM_CLASS_RESOLVE, /* call resolution evidence and coverage capture */
/* The daemon's in-memory adjacency layers (store/graph_mem.h): resident
* across requests, so they are subtracted from index-worker budgets and
* must read apart from SQLite's request-scoped memory (STORE). */
+443 -59
View File
@@ -74,6 +74,7 @@ enum {
#include "watcher/watcher.h"
#include "foundation/mem.h"
#include "foundation/mem_core.h"
#include "foundation/hash_table.h"
#include "foundation/json_writer.h"
#include "foundation/arena.h"
#include "foundation/diagnostics.h"
@@ -4012,6 +4013,7 @@ enum {
BM25_BIND_EXACT = 8,
BM25_BIND_KIND = 9,
BM25_BIND_NS = 10,
BM25_BIND_LABEL_WEIGHT = 11,
BM25_SQL_AUTO_LEN = -1,
/* Inner FTS5 candidate cap. SQLite can early-terminate a plain FTS5 query
* (no JOIN/WHERE on outer table) of the form:
@@ -4020,6 +4022,11 @@ enum {
* and then joining/filtering/re-ranking those, we bound all work to O(N) where
* N = BM25_INNER_LIMIT rather than the full match set size. */
BM25_INNER_LIMIT = 2000,
/* Longest label spliced into the MATCH pre-filter; real labels are short
* identifiers, so anything longer simply skips the pre-filter. */
BM25_LABEL_MAX = 64,
/* Room for "(<tokens>) AND label : \"<label>\"" around the token list. */
BM25_MATCH_BUF = BM25_QUERY_BUF + BM25_LABEL_MAX + 32,
};
/* Column weights for nodes_fts (name, qualified_name, label, file_path, body).
@@ -4038,8 +4045,28 @@ enum {
* Safe against a legacy four-column nodes_fts: FTS5's bm25() reads a weight
* only when an instance actually lands in that column (`nVal > ic`), so the
* fifth weight is simply never consulted on a table that has no fifth
* column. */
#define BM25_WEIGHTS "bm25(nodes_fts, 1.0, 1.0, 1.0, 1.0, 0.3)"
* column.
*
* The label weight is bound (?11): 1.0 normally, 0.0 when the MATCH is scoped
* to a label (#2386). Every candidate then carries that label, so a label hit
* cannot tell them apart, and a zero weight keeps the added `label : "..."`
* phrase from shifting BM25 order by row length. */
#define BM25_WEIGHTS "bm25(nodes_fts, 1.0, 1.0, ?11, 1.0, 0.3)"
static const double BM25_LABEL_WEIGHT = 1.0;
static const double BM25_LABEL_WEIGHT_SCOPED = 0.0;
/* Labels an UNLABELLED query never ranks: path-shaped File/Folder nodes, the
* Project node and Variables match almost any token through their qualified
* name and file path, and would crowd definitions out of the result. They are
* still in nodes_fts, so an explicit `label` that names one of them is served
* from its own label-scoped window like any other label (?7 is the caller's
* label, NULL when none was given). Excluding them there too returned 0 rows
* -- often with an exact "eq" total -- although such nodes match. Section and
* Module are NOT excluded (#518/#519): they carry the prose the body column
* indexes. Shared by the ranked and the count query, so the two cannot
* desynchronise. */
#define BM25_UNLABELLED_EXCLUSION \
"(?7 IS NOT NULL OR n.label NOT IN ('File','Folder','Variable','Project'))"
/* Module-local SQLITE_TRANSIENT wrapper to dodge performance-no-int-to-ptr.
* See the matching helper in src/store/store.c for the same pattern. */
@@ -4090,6 +4117,85 @@ static int bm25_build_match(const char *query, char *out, size_t out_size) {
return tokens;
}
/* Scope an FTS5 MATCH expression to one node label (#2386). The label filter
* must choose the BM25_INNER_LIMIT candidate window, not trim it afterwards:
* otherwise a label that is rare among a common token's matches falls outside
* the window and the search returns 0 rows although matching nodes exist.
* FTS5 matching is case-insensitive, so this is a superset pre-filter; the
* caller keeps the exact `n.label = ?` check. Only a plain identifier is
* spliced in, as a quoted FTS5 string; any other label leaves `tokens`
* unscoped, which is the pre-#2386 behaviour. Returns the label-column weight
* to bind for the resulting MATCH. */
static double bm25_scope_match_to_label(const char *tokens, const char *label, char *out,
size_t out_size) {
snprintf(out, out_size, "%s", tokens);
if (!label || !label[0]) {
return BM25_LABEL_WEIGHT;
}
size_t len = 0;
for (; label[len]; len++) {
char ch = label[len];
bool ident = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9') || ch == '_';
if (!ident || len >= BM25_LABEL_MAX) {
return BM25_LABEL_WEIGHT;
}
}
char scoped[BM25_MATCH_BUF];
int n = snprintf(scoped, sizeof(scoped), "(%s) AND label : \"%s\"", tokens, label);
if (n <= 0 || (size_t)n >= sizeof(scoped) || (size_t)n >= out_size) {
return BM25_LABEL_WEIGHT;
}
memcpy(out, scoped, (size_t)n + 1);
return BM25_LABEL_WEIGHT_SCOPED;
}
/* A BM25 result is empty although nodes match when the label is spelled in a
* case no node carries: labels are case-sensitive, so `label=class` finds
* nothing while Class nodes match the query. Returns a hint (memory core,
* CBM_MEM_CLASS_OTHER) naming the label this project actually holds, or NULL
* when the label exists exactly (a true zero). The exact probe is an index
* seek on (project, label); the case-insensitive scan runs only when it
* misses. The caller's label is never echoed back, only the stored spelling. */
static char *bm25_label_zero_hint(sqlite3 *db, const char *project, const char *label) {
enum { HINT_BIND_PROJECT = 1, HINT_BIND_LABEL = 2, HINT_COL_LABEL = 0, HINT_PROBE_EXACT = 0 };
if (!db || !label || !label[0]) {
return NULL;
}
static const char *const probes[] = {
"SELECT label FROM nodes WHERE project = ?1 AND label = ?2 LIMIT 1",
"SELECT label FROM nodes WHERE project = ?1 AND label = ?2 COLLATE NOCASE LIMIT 1",
};
char found[CBM_SZ_256] = "";
for (size_t i = 0; i < sizeof(probes) / sizeof(probes[0]) && !found[0]; i++) {
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db, probes[i], BM25_SQL_AUTO_LEN, &st, NULL) != SQLITE_OK) {
return NULL;
}
sqlite3_bind_text(st, HINT_BIND_PROJECT, project, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
sqlite3_bind_text(st, HINT_BIND_LABEL, label, BM25_SQL_AUTO_LEN, MCP_SQLITE_TRANSIENT);
if (sqlite3_step(st) == SQLITE_ROW) {
const char *stored = (const char *)sqlite3_column_text(st, HINT_COL_LABEL);
snprintf(found, sizeof(found), "%s", stored ? stored : "");
}
sqlite3_finalize(st);
if (found[0] && i == HINT_PROBE_EXACT) {
return NULL;
}
}
if (!found[0]) {
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER,
"no node in this project carries this label; get_graph_schema "
"lists the labels this index holds");
}
char hint[CBM_SZ_512];
snprintf(hint, sizeof(hint),
"no node carries this label as spelled; labels are case-sensitive -- retry with "
"label=\"%s\"",
found);
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, hint);
}
static char *bm25_file_pattern_like(const char *file_pattern) {
if (!file_pattern) {
return NULL;
@@ -4141,7 +4247,8 @@ static void bm25_lines_str(char *out, size_t size, int start, int end) {
static void bm25_emit_metadata_tree(cbm_sb_t *sb, int total, int offset, int returned,
bool candidate_window_saturated, bool budget_hit,
bool floor_exceeded, size_t max_output_bytes) {
bool floor_exceeded, size_t max_output_bytes,
const char *zero_hint) {
bool has_more = total > offset + returned;
cbm_tree_scalar_int(sb, "total", total);
cbm_tree_scalar_str(sb, "total_relation", candidate_window_saturated ? "gte" : "eq");
@@ -4172,12 +4279,15 @@ static void bm25_emit_metadata_tree(cbm_sb_t *sb, int total, int offset, int ret
cbm_tree_scalar_str(sb, "hint",
"first identity row exceeds max_output_bytes; raise "
"max_output_tokens");
} else if (zero_hint) {
cbm_tree_scalar_str(sb, "hint", zero_hint);
}
}
static void bm25_emit_metadata_json(yyjson_mut_doc *doc, yyjson_mut_val *root, int total,
int offset, int returned, bool candidate_window_saturated,
bool budget_hit, bool floor_exceeded, size_t max_output_bytes) {
bool budget_hit, bool floor_exceeded, size_t max_output_bytes,
const char *zero_hint) {
bool has_more = total > offset + returned;
yyjson_mut_obj_add_int(doc, root, "total", total);
yyjson_mut_obj_add_str(doc, root, "total_relation", candidate_window_saturated ? "gte" : "eq");
@@ -4209,12 +4319,14 @@ static void bm25_emit_metadata_json(yyjson_mut_doc *doc, yyjson_mut_val *root, i
yyjson_mut_obj_add_str(doc, root, "hint",
"first identity row exceeds max_output_bytes; raise "
"max_output_tokens");
} else if (zero_hint) {
yyjson_mut_obj_add_strcpy(doc, root, "hint", zero_hint);
}
}
static char *bm25_render(const bm25_output_row_t *rows, int returned, int total, int offset,
bool candidate_window_saturated, bool tree_format, bool budget_hit,
bool floor_exceeded, size_t max_output_bytes) {
bool floor_exceeded, size_t max_output_bytes, const char *zero_hint) {
if (tree_format) {
cbm_sb_t sb;
cbm_sb_init(&sb);
@@ -4232,7 +4344,7 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total,
cbm_tree_row_end(&sb);
}
bm25_emit_metadata_tree(&sb, total, offset, returned, candidate_window_saturated,
budget_hit, floor_exceeded, max_output_bytes);
budget_hit, floor_exceeded, max_output_bytes, zero_hint);
return cbm_sb_finish(&sb);
}
@@ -4259,7 +4371,7 @@ static char *bm25_render(const bm25_output_row_t *rows, int returned, int total,
}
yyjson_mut_obj_add_val(doc, root, "rows", result_rows);
bm25_emit_metadata_json(doc, root, total, offset, returned, candidate_window_saturated,
budget_hit, floor_exceeded, max_output_bytes);
budget_hit, floor_exceeded, max_output_bytes, zero_hint);
char *json = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return json;
@@ -4276,11 +4388,16 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
if (!db) {
return NULL;
}
char fts_query[BM25_QUERY_BUF];
int tok_count = bm25_build_match(query, fts_query, sizeof(fts_query));
char fts_tokens[BM25_QUERY_BUF];
int tok_count = bm25_build_match(query, fts_tokens, sizeof(fts_tokens));
if (tok_count == 0) {
return NULL;
}
/* One MATCH string for the ranked query, the count and the saturation
* probe, so all three describe the same label-scoped window. */
char fts_query[BM25_MATCH_BUF];
double label_weight =
bm25_scope_match_to_label(fts_tokens, label, fts_query, sizeof(fts_query));
char *file_like = bm25_file_pattern_like(file_pattern);
/* BM25 ranked query using a two-step approach to enable FTS5 early termination.
@@ -4323,12 +4440,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
") fts "
"JOIN nodes n ON n.id = fts.rowid "
"WHERE n.project = ?2 "
/* Section and Module are NO LONGER excluded (#518/#519): they are the
* labels that carry prose — a Markdown section's body, a config file's
* description — so excluding them made the body column unreachable.
* This exclusion list is MIRRORED in the count query below; the two
* must be changed together or results desynchronise from counts. */
" AND n.label NOT IN ('File','Folder','Variable','Project') "
" AND " BM25_UNLABELLED_EXCLUSION " "
" AND (?6 IS NULL OR n.file_path LIKE ?6) "
/* The caller's label filter applies in query mode exactly as it does
* in the structural mode (2026-09-16 probe: `label=Class` was ignored
@@ -4378,6 +4490,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
} else {
sqlite3_bind_null(stmt, BM25_BIND_NS);
}
sqlite3_bind_double(stmt, BM25_BIND_LABEL_WEIGHT, label_weight);
/* Count hits within the same inner-limit window — capped at BM25_INNER_LIMIT.
* Uses the identical subquery structure so the FTS5 early-exit applies here too. */
@@ -4394,7 +4507,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
/* MIRRORS the ranked query's filter verbatim — same weights, same
* label exclusions. Changing one alone reports a total that does
* not describe the rows returned. */
" AND n.label NOT IN ('File','Folder','Variable','Project')"
" AND " BM25_UNLABELLED_EXCLUSION " "
" AND (?6 IS NULL OR n.file_path LIKE ?6)"
" AND (?7 IS NULL OR n.label = ?7)"
" AND (?9 IS NULL OR (CASE WHEN json_valid(n.properties) "
@@ -4411,6 +4524,7 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
sqlite3_bind_text(cs, BM25_BIND_PROJECT, project, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
sqlite3_bind_int(cs, BM25_BIND_LIMIT, BM25_INNER_LIMIT);
sqlite3_bind_double(cs, BM25_BIND_LABEL_WEIGHT, label_weight);
if (file_like) {
sqlite3_bind_text(cs, BM25_BIND_FILE, file_like, BM25_SQL_AUTO_LEN,
MCP_SQLITE_TRANSIENT);
@@ -4443,9 +4557,10 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
/* The top-candidate window is a performance ceiling, not an exact-total
* boundary. Probe one candidate beyond it so a broad query never presents
* a window-local count as the complete match count. This is global to the
* FTS table; saturation is therefore conservatively reported even when
* later project/path filters might discard the hidden candidates. */
* a window-local count as the complete match count. The probe shares the
* label-scoped MATCH but is otherwise global to the FTS table; saturation
* is therefore conservatively reported even when later project/path
* filters might discard the hidden candidates. */
bool candidate_window_saturated = true;
{
const char *probe_sql = "SELECT rowid FROM nodes_fts WHERE nodes_fts MATCH ?1 "
@@ -4483,13 +4598,15 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
if (!rows) {
return heap_strdup("out of memory");
}
/* Never a silent zero for a label the index spells differently. */
char *zero_hint = total == 0 ? bm25_label_zero_hint(db, project, label) : NULL;
int returned = row_count;
bool budget_hit = false;
char *payload = NULL;
for (;;) {
payload = bm25_render(rows, returned, total, offset, candidate_window_saturated,
tree_format, budget_hit, false, max_output_bytes);
tree_format, budget_hit, false, max_output_bytes, zero_hint);
if (!payload || strlen(payload) <= max_output_bytes || returned == 0) {
break;
}
@@ -4502,8 +4619,9 @@ static char *bm25_search(cbm_store_t *store, const char *project, const char *qu
if (!payload || strlen(payload) > max_output_bytes || (row_count > 0 && returned == 0)) {
free(payload);
payload = bm25_render(rows, 0, total, offset, candidate_window_saturated, tree_format, true,
row_count > 0, max_output_bytes);
row_count > 0, max_output_bytes, zero_hint);
}
cbm_free(CBM_MEM_CLASS_OTHER, zero_hint);
bm25_output_rows_free(rows, row_count);
return payload;
}
@@ -6718,11 +6836,13 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s
yyjson_mut_val *pp_files = yyjson_mut_arr(doc);
yyjson_mut_val *pu_files = yyjson_mut_arr(doc);
yyjson_mut_val *unresolved_files = yyjson_mut_arr(doc);
yyjson_mut_val *sk_files = yyjson_mut_arr(doc);
yyjson_mut_val *ni_dirs = yyjson_mut_arr(doc);
yyjson_mut_val *ni_files = yyjson_mut_arr(doc);
int pp_n = 0;
int pu_n = 0;
int unresolved_n = 0;
int sk_n = 0;
int ni_dir_n = 0;
int ni_file_n = 0;
@@ -6752,6 +6872,11 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s
yyjson_mut_arr_add_val(pu_files, fe);
}
pu_n++;
} else if (strcmp(kind, "unresolved_calls") == 0) {
if (unresolved_n < sample_limit) {
yyjson_mut_arr_add_strcpy(doc, unresolved_files, rows[i].rel_path);
}
unresolved_n++;
} else if (strcmp(kind, "not_indexed_dir") == 0) {
if (ni_dir_n < sample_limit) {
yyjson_mut_arr_add_strcpy(doc, ni_dirs, rows[i].rel_path);
@@ -6809,6 +6934,12 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s
yyjson_mut_obj_add_bool(doc, pu, "truncated", pu_n > COVERAGE_FILE_CAP);
yyjson_mut_obj_add_val(doc, root, "parse_unusable", pu);
yyjson_mut_val *unresolved = yyjson_mut_obj(doc);
yyjson_mut_obj_add_val(doc, unresolved, "files", unresolved_files);
yyjson_mut_obj_add_int(doc, unresolved, "count", unresolved_n);
yyjson_mut_obj_add_bool(doc, unresolved, "truncated", unresolved_n > sample_limit);
yyjson_mut_obj_add_val(doc, root, "unresolved_calls", unresolved);
yyjson_mut_val *sk = yyjson_mut_obj(doc);
yyjson_mut_obj_add_val(doc, sk, "files", sk_files);
yyjson_mut_obj_add_int(doc, sk, "count", sk_n);
@@ -6846,7 +6977,7 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s
}
yyjson_mut_obj_add_val(doc, root, "not_indexed", ni);
if (sample_limit > 0 && (pp_n > 0 || sk_n > 0)) {
if (sample_limit > 0 && (pp_n > 0 || sk_n > 0 || unresolved_n > 0)) {
yyjson_mut_obj_add_str(
doc, root, "coverage_note",
"Best-effort signal, not a completeness guarantee: parse_partial files WERE indexed, "
@@ -6854,7 +6985,9 @@ static void add_coverage_report(yyjson_mut_doc *doc, yyjson_mut_val *root, cbm_s
"(tree-sitter error recovery still salvages some). skipped files were not indexed at "
"all. Prefer text search (grep) for flagged files/ranges. Files absent from this list "
"are NOT guaranteed to be fully indexed. (not_indexed entries are a separate, "
"BY-DESIGN class — deliberate ignore rules, not failures.)");
"BY-DESIGN class — deliberate ignore rules, not failures.) "
"Unresolved callsites need source verification because CALLS totals may be "
"incomplete.");
}
}
@@ -7066,6 +7199,43 @@ static void coverage_add_row_json(yyjson_mut_doc *doc, yyjson_mut_val *array,
yyjson_mut_arr_add_val(array, item);
}
enum { COVERAGE_EXCLUDED_PREFIX_LENGTH = 11 };
enum {
COVERAGE_STATUS_PARSE,
COVERAGE_STATUS_EXCLUDED,
COVERAGE_STATUS_SKIPPED,
COVERAGE_STATUS_UNRESOLVED,
COVERAGE_STATUS_PASSES
};
static const char *coverage_row_status(const char *kind, int pass) {
kind = kind ? kind : "";
if (pass == COVERAGE_STATUS_PARSE) {
if (strcmp(kind, "parse_unusable") == 0) {
return "unusable";
}
if (strcmp(kind, "parse_partial") == 0) {
return "partial";
}
}
if (pass == COVERAGE_STATUS_EXCLUDED &&
strncmp(kind, "not_indexed", COVERAGE_EXCLUDED_PREFIX_LENGTH) == 0) {
return "excluded";
}
if (pass == COVERAGE_STATUS_SKIPPED && kind[0] && strcmp(kind, "unresolved_calls") != 0) {
return "skipped";
}
if (pass == COVERAGE_STATUS_UNRESOLVED && strcmp(kind, "unresolved_calls") == 0) {
return "unresolved_calls";
}
return NULL;
}
static bool coverage_unresolved_complete(bool have_meta, const cbm_coverage_meta_t *meta) {
return have_meta && meta->unresolved_calls_complete;
}
static const char *coverage_status(const cbm_coverage_row_t *rows, int count,
const char *requested_path, const char *recording_status,
bool generation_matches, bool lookup_ok,
@@ -7080,26 +7250,14 @@ static const char *coverage_status(const cbm_coverage_row_t *rows, int count,
break;
}
}
for (int pass = 0; pass < 3; pass++) {
for (int pass = 0; pass < COVERAGE_STATUS_PASSES; pass++) {
for (int i = 0; i < count; i++) {
if (exact && (!rows[i].rel_path || strcmp(rows[i].rel_path, requested_path) != 0)) {
continue;
}
const char *kind = rows[i].kind ? rows[i].kind : "";
/* "parse_unusable" must be named here. Without its own case it
* falls through to the catch-all below and reports "skipped",
* which is wrong in the way that matters: the file WAS indexed. */
if (pass == 0 && strcmp(kind, "parse_unusable") == 0) {
return "unusable";
}
if (pass == 0 && strcmp(kind, "parse_partial") == 0) {
return "partial";
}
if (pass == 1 && strncmp(kind, "not_indexed", 11) == 0) {
return "excluded";
}
if (pass == 2 && kind[0]) {
return "skipped";
const char *status = coverage_row_status(rows[i].kind, pass);
if (status) {
return status;
}
}
}
@@ -7119,6 +7277,9 @@ static const char *coverage_recommended_action(const char *status, const char *f
if (strcmp(status, "partial") == 0) {
return "read_ranges_and_verify_scope";
}
if (strcmp(status, "unresolved_calls") == 0) {
return "read_source_and_verify_calls";
}
if (strcmp(status, "unusable") == 0) {
/* The ranges cover nearly the whole file, so sending a reader to them
* is the same as sending them to the file. Say the useful thing. */
@@ -7202,6 +7363,8 @@ static char *handle_check_index_coverage(cbm_mcp_server_t *srv, const char *args
have_meta ? meta.ignored_files_total : 0);
yyjson_mut_obj_add_bool(doc, meta_obj, "hash_records_complete",
have_meta && meta.hash_records_complete);
yyjson_mut_obj_add_bool(doc, meta_obj, "unresolved_calls_complete",
coverage_unresolved_complete(have_meta, &meta));
yyjson_mut_obj_add_int(doc, meta_obj, "coverage_version",
have_meta ? meta.coverage_version : 0);
yyjson_mut_obj_add_bool(doc, meta_obj, "generation_matches", generation_matches);
@@ -9773,6 +9936,207 @@ static int clamp_mcp_depth(int depth, const char *tool) {
return depth;
}
typedef struct {
const cbm_node_t *roots;
int root_count;
const cbm_traverse_result_t *outbound;
const cbm_traverse_result_t *inbound;
bool include_tests;
bool do_outbound;
bool do_inbound;
} trace_coverage_scope_t;
typedef struct {
cbm_store_t *store;
const char *project;
CBMHashTable *callers;
CBMHashTable *targets;
bool unresolved_out;
bool unresolved_in;
} trace_coverage_match_t;
static bool trace_coverage_add_node(CBMHashTable *table, const cbm_node_t *node) {
if (!table || !node->qualified_name) {
return true;
}
cbm_ht_set(table, node->qualified_name, (void *)node);
return cbm_ht_has(table, node->qualified_name);
}
static bool trace_coverage_add_visited(CBMHashTable *table, const cbm_traverse_result_t *tr,
bool include_tests) {
if (!table) {
return true;
}
for (int i = 0; i < tr->visited_count; i++) {
const cbm_node_t *node = &tr->visited[i].node;
if ((include_tests || !is_test_file(node->file_path)) &&
!trace_coverage_add_node(table, node)) {
return false;
}
}
return true;
}
static bool trace_coverage_build_sets(trace_coverage_match_t *match,
const trace_coverage_scope_t *scope) {
for (int i = 0; i < scope->root_count; i++) {
if (!trace_coverage_add_node(match->callers, &scope->roots[i]) ||
!trace_coverage_add_node(match->targets, &scope->roots[i])) {
return false;
}
}
return trace_coverage_add_visited(match->callers, scope->outbound, scope->include_tests) &&
trace_coverage_add_visited(match->targets, scope->inbound, scope->include_tests);
}
static bool trace_coverage_may_target_project(const trace_coverage_match_t *match,
const char *name) {
if (!name || !name[0]) {
return true;
}
cbm_node_t *nodes = NULL;
int count = 0;
int rc = cbm_store_find_nodes_by_name(match->store, match->project, name, &nodes, &count);
/* An unsuccessful lookup cannot prove that the callee is outside the
* project. Free the allocated result even when it contains zero rows. */
bool possible = rc != CBM_STORE_OK;
for (int i = 0; i < count && !possible; i++) {
const char *label = nodes[i].label;
possible = label && strcmp(label, "File") != 0 && strcmp(label, "Folder") != 0 &&
strcmp(label, "Project") != 0 && strcmp(label, "Module") != 0 &&
strcmp(label, "Package") != 0 && strcmp(label, "Section") != 0;
}
cbm_store_free_nodes(nodes, count);
return possible;
}
static void trace_coverage_match_site(trace_coverage_match_t *match, const char *rel_path,
yyjson_val *site) {
const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller"));
const char *leaf = yyjson_get_str(yyjson_obj_get(site, "leaf"));
const cbm_node_t *caller_node =
match->callers && caller ? cbm_ht_get(match->callers, caller) : NULL;
int line = yyjson_get_int(yyjson_obj_get(site, "line"));
/* The producer joins the exact byte span to the extractor's innermost
* caller. Check its file and source range too; never match a containing
* factory merely because its range overlaps. */
if (caller_node && caller_node->file_path && strcmp(caller_node->file_path, rel_path) == 0 &&
(line <= 0 || (line >= caller_node->start_line && line <= caller_node->end_line)) &&
trace_coverage_may_target_project(match, leaf)) {
match->unresolved_out = true;
}
}
static void trace_coverage_match_row(trace_coverage_match_t *match, const cbm_coverage_row_t *row) {
yyjson_doc *detail = yyjson_read(row->detail, strlen(row->detail), 0);
yyjson_val *sites = detail ? yyjson_doc_get_root(detail) : NULL;
if (!yyjson_is_arr(sites)) {
match->unresolved_out = true;
} else {
size_t idx;
size_t max;
yyjson_val *site;
yyjson_arr_foreach(sites, idx, max, site) {
if (!yyjson_is_obj(site) || yyjson_get_bool(yyjson_obj_get(site, "truncated"))) {
match->unresolved_out = true;
break;
}
trace_coverage_match_site(match, row->rel_path, site);
}
}
if (detail) {
yyjson_doc_free(detail);
}
}
typedef struct {
trace_coverage_match_t *match;
CBMHashTable *files;
bool ok;
} trace_coverage_query_t;
static void trace_coverage_query_file(const char *key, void *value, void *userdata) {
(void)key;
const cbm_node_t *caller = value;
trace_coverage_query_t *query = userdata;
const char *path = caller->file_path;
if (!query->ok || query->match->unresolved_out || !path || cbm_ht_has(query->files, path)) {
return;
}
cbm_ht_set(query->files, path, value);
if (!cbm_ht_has(query->files, path)) {
query->ok = false;
return;
}
cbm_coverage_row_t *rows = NULL;
int count = 0;
query->ok = cbm_store_coverage_get_unresolved_path(query->match->store, query->match->project,
path, &rows, &count) == CBM_STORE_OK;
for (int i = 0; query->ok && i < count; i++) {
trace_coverage_match_row(query->match, &rows[i]);
}
cbm_store_free_coverage(rows, count);
}
static void trace_coverage_query_candidate(const char *key, void *value, void *userdata) {
(void)value;
trace_coverage_query_t *query = userdata;
if (!query->ok || query->match->unresolved_in) {
return;
}
query->ok =
cbm_store_coverage_has_unresolved_candidate(query->match->store, query->match->project, key,
&query->match->unresolved_in) == CBM_STORE_OK;
}
static bool trace_coverage_query_evidence(trace_coverage_match_t *match,
const trace_coverage_scope_t *scope) {
trace_coverage_query_t query = {.match = match, .ok = true};
if (scope->do_outbound) {
query.files = cbm_ht_create(0);
if (!query.files) {
return false;
}
cbm_ht_foreach(match->callers, trace_coverage_query_file, &query);
cbm_ht_free(query.files);
}
if (scope->do_inbound) {
cbm_ht_foreach(match->targets, trace_coverage_query_candidate, &query);
}
return query.ok;
}
static bool trace_coverage_meta_available(cbm_store_t *store, const char *project) {
cbm_coverage_meta_t meta = {0};
int rc = cbm_store_coverage_meta_get(store, project, &meta);
bool available = rc == CBM_STORE_OK &&
meta.coverage_version >= CBM_UNRESOLVED_CALL_COVERAGE_VERSION &&
meta.unresolved_calls_complete;
cbm_store_coverage_meta_clear(&meta);
return available;
}
static void trace_call_coverage(cbm_store_t *store, const char *project,
const trace_coverage_scope_t *scope, bool includes_calls,
bool *unresolved_out, bool *unresolved_in) {
if (!includes_calls) {
return;
}
trace_coverage_match_t match = {.store = store,
.project = project,
.callers = scope->do_outbound ? cbm_ht_create(0) : NULL,
.targets = scope->do_inbound ? cbm_ht_create(0) : NULL};
bool ok = trace_coverage_meta_available(store, project) &&
(!scope->do_outbound || match.callers) && (!scope->do_inbound || match.targets) &&
trace_coverage_build_sets(&match, scope) &&
trace_coverage_query_evidence(&match, scope);
*unresolved_out = ok ? match.unresolved_out : scope->do_outbound;
*unresolved_in = ok ? match.unresolved_in : scope->do_inbound;
cbm_ht_free(match.callers);
cbm_ht_free(match.targets);
}
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
char *project = get_project_arg(args);
@@ -10109,6 +10473,27 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
bool optional_fields_omitted = false;
char *json = NULL;
bool trace_includes_calls = false;
for (int i = 0; i < edge_type_count; i++) {
if (strcmp(edge_types[i], "CALLS") == 0) {
trace_includes_calls = true;
break;
}
}
bool unresolved_out = false;
bool unresolved_in = false;
trace_coverage_scope_t coverage_scope = {.roots = nodes,
.root_count = node_count,
.outbound = &tr_out,
.inbound = &tr_in,
.include_tests = include_tests,
.do_outbound = do_outbound,
.do_inbound = do_inbound};
trace_call_coverage(store, project, &coverage_scope, trace_includes_calls, &unresolved_out,
&unresolved_in);
const char *out_relation = unresolved_out ? "unknown" : (tr_out.truncated ? "gte" : "eq");
const char *in_relation = unresolved_in ? "unknown" : (tr_in.truncated ? "gte" : "eq");
render_trace_output:;
int rows_left = row_target;
out_len = requested_out_len < rows_left ? requested_out_len : rows_left;
@@ -10197,7 +10582,7 @@ render_trace_output:;
bool flat_trace = render_risk || render_data_flow;
if (do_outbound) {
cbm_tree_scalar_int(&sb, "callees_total", out_total);
cbm_tree_scalar_str(&sb, "callees_total_relation", tr_out.truncated ? "gte" : "eq");
cbm_tree_scalar_str(&sb, "callees_total_relation", out_relation);
if (flat_trace) {
bfs_to_toon_table(&sb, "callees", &view_out, render_risk, include_tests,
render_data_flow, render_evidence, &out_edge_ctx);
@@ -10208,7 +10593,7 @@ render_trace_output:;
}
if (do_inbound) {
cbm_tree_scalar_int(&sb, "callers_total", in_total);
cbm_tree_scalar_str(&sb, "callers_total_relation", tr_in.truncated ? "gte" : "eq");
cbm_tree_scalar_str(&sb, "callers_total_relation", in_relation);
if (flat_trace) {
bfs_to_toon_table(&sb, "callers", &view_in, render_risk, include_tests,
render_data_flow, render_evidence, &in_edge_ctx);
@@ -10274,8 +10659,7 @@ render_trace_output:;
}
if (do_outbound) {
yyjson_mut_obj_add_int(doc, root, "callees_total", out_total);
yyjson_mut_obj_add_str(doc, root, "callees_total_relation",
tr_out.truncated ? "gte" : "eq");
yyjson_mut_obj_add_str(doc, root, "callees_total_relation", out_relation);
yyjson_mut_obj_add_val(
doc, root, "callees",
bfs_to_tree_json(doc, &view_out, risk_labels && emit_optional_fields, include_tests,
@@ -10284,8 +10668,7 @@ render_trace_output:;
}
if (do_inbound) {
yyjson_mut_obj_add_int(doc, root, "callers_total", in_total);
yyjson_mut_obj_add_str(doc, root, "callers_total_relation",
tr_in.truncated ? "gte" : "eq");
yyjson_mut_obj_add_str(doc, root, "callers_total_relation", in_relation);
yyjson_mut_obj_add_val(
doc, root, "callers",
bfs_to_tree_json(doc, &view_in, risk_labels && emit_optional_fields, include_tests,
@@ -10343,13 +10726,11 @@ render_trace_output:;
cbm_sb_init(&floor);
if (do_outbound) {
cbm_tree_scalar_int(&floor, "callees_total", out_total);
cbm_tree_scalar_str(&floor, "callees_total_relation",
tr_out.truncated ? "gte" : "eq");
cbm_tree_scalar_str(&floor, "callees_total_relation", out_relation);
}
if (do_inbound) {
cbm_tree_scalar_int(&floor, "callers_total", in_total);
cbm_tree_scalar_str(&floor, "callers_total_relation",
tr_in.truncated ? "gte" : "eq");
cbm_tree_scalar_str(&floor, "callers_total_relation", in_relation);
}
cbm_tree_scalar_bool(&floor, "has_more", floor_has_more);
if (floor_has_more) {
@@ -10374,12 +10755,11 @@ render_trace_output:;
if (do_outbound) {
yyjson_mut_obj_add_int(floor_doc, floor, "callees_total", out_total);
yyjson_mut_obj_add_str(floor_doc, floor, "callees_total_relation",
tr_out.truncated ? "gte" : "eq");
out_relation);
}
if (do_inbound) {
yyjson_mut_obj_add_int(floor_doc, floor, "callers_total", in_total);
yyjson_mut_obj_add_str(floor_doc, floor, "callers_total_relation",
tr_in.truncated ? "gte" : "eq");
yyjson_mut_obj_add_str(floor_doc, floor, "callers_total_relation", in_relation);
}
yyjson_mut_obj_add_bool(floor_doc, floor, "has_more", floor_has_more);
if (floor_has_more) {
@@ -10895,11 +11275,12 @@ static bool is_parse_unusable(const cbm_file_error_t *e) {
return e->phase && strcmp(e->phase, "parse_unusable") == 0;
}
/* Either coverage phase. Both mean the file WAS indexed, so both must stay out
/* All coverage phases mean the file WAS indexed, so they must stay out
* of skipped[] — a reader who sees a file there believes it is absent from the
* graph entirely. */
static bool is_parse_coverage(const cbm_file_error_t *e) {
return is_parse_partial(e) || is_parse_unusable(e);
static bool is_indexed_coverage(const cbm_file_error_t *e) {
return is_parse_partial(e) || is_parse_unusable(e) ||
(e->phase && strcmp(e->phase, "unresolved_calls") == 0);
}
/* Attach a summary of per-file skips (Stage 2 / Track B). Always emits a
@@ -10909,17 +11290,20 @@ static bool is_parse_coverage(const cbm_file_error_t *e) {
* and, if a per-run logfile was written, "logfile": "<path>".
* The run status stays "indexed" — a skipped file is the expected handled
* outcome, not a failure. errs[] is borrowed (copied into doc) and may contain
* parse_partial and parse_unusable entries, which are filtered out here (both
* reported separately by add_parse_partial_summary). */
* parse_partial, parse_unusable, and unresolved_calls entries. These describe
* indexed files and are counted separately from skips. */
static void add_skipped_summary(yyjson_mut_doc *doc, yyjson_mut_val *root,
const cbm_file_error_t *errs, int count, const char *logfile) {
int skips = 0;
int unresolved = 0;
for (int i = 0; i < count; i++) {
if (!is_parse_coverage(&errs[i])) {
unresolved += errs[i].phase && strcmp(errs[i].phase, "unresolved_calls") == 0;
if (!is_indexed_coverage(&errs[i])) {
skips++;
}
}
yyjson_mut_obj_add_int(doc, root, "skipped_count", skips);
yyjson_mut_obj_add_int(doc, root, "unresolved_calls_count", unresolved);
if (logfile && logfile[0]) {
yyjson_mut_obj_add_strcpy(doc, root, "logfile", logfile);
}
@@ -10930,7 +11314,7 @@ static void add_skipped_summary(yyjson_mut_doc *doc, yyjson_mut_val *root,
yyjson_mut_val *files = yyjson_mut_arr(doc);
int shown = 0;
for (int i = 0; i < count && shown < INDEX_SKIPPED_FILE_CAP; i++) {
if (is_parse_coverage(&errs[i])) {
if (is_indexed_coverage(&errs[i])) {
continue;
}
yyjson_mut_val *fe = yyjson_mut_obj(doc);
+256 -154
View File
@@ -11,7 +11,13 @@
*/
#include "foundation/constants.h"
enum { PC_RING = 4, PC_RING_MASK = 3, PC_SIG_SCAN = 15, PC_REGEX_GRP = 2 };
enum {
PC_RING = 4,
PC_RING_MASK = 3,
PC_SIG_SCAN = 15,
PC_REGEX_GRP = 2,
PC_SINGLE_CANDIDATE_COUNT = 1
};
/* Confidence for a service-pattern HTTP/ASYNC edge emitted when registry
* resolution is empty (external, unindexed client library) — see #523. */
#define PC_SVC_PATTERN_CONF 0.5
@@ -574,7 +580,7 @@ cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t
/* Handle a route registration call: create Route node + HANDLES edge.
* route_mount is the framework mount of the registering file ("/api" for a
* Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */
static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
static bool handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source_node, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount, CBMLanguage lang) {
@@ -598,7 +604,7 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca
snprintf(props, sizeof(props),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn,
esc_fa);
cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props);
bool emitted = cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, route_id, "CALLS", props) > 0;
if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') {
/* Python: an app factory registers its local defs
* (`app.add_url_rule("/", "index", index)` inside create_app); only the
@@ -624,6 +630,7 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca
}
}
}
return emitted;
}
/* Emit an HTTP/async route edge for a service call. */
@@ -725,7 +732,7 @@ void cbm_pipeline_calls_append_args_for_tests(char *props, size_t cap, const CBM
calls_append_args(props, cap, call);
}
static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type,
static bool calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const char *type,
char *props, size_t cap, const CBMCall *call) {
if (call && call->start_line > 0 && strcmp(type, "CALLS") == 0) {
size_t len = strlen(props);
@@ -737,10 +744,11 @@ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const ch
if (call && strcmp(type, "CALLS") == 0) {
calls_append_args(props, cap, call);
}
cbm_gbuf_insert_edge(gbuf, src, tgt, type, props);
return cbm_gbuf_insert_edge(gbuf, src, tgt, type, props) > 0 && call &&
strcmp(type, "CALLS") == 0;
}
static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
static bool emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target,
const cbm_resolution_t *res, cbm_svc_kind_t svc,
bool suppress_plain_calls) {
@@ -761,7 +769,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
* target->id into a null dereference (clang-analyzer traced exactly
* that), and with no callee node there is nothing to emit anyway. */
if (suppress_plain_calls || !target) {
return;
return false;
}
char esc_callee[CBM_SZ_256];
cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name);
@@ -770,8 +778,8 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
"{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d}",
esc_callee, res->confidence, res->strategy ? res->strategy : "unknown",
res->candidate_count);
calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), call);
return;
return calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props),
call);
}
const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS";
const char *method =
@@ -801,6 +809,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
}
cbm_pipeline_close_call_props(props, n, call, false);
cbm_gbuf_insert_edge(ctx->gbuf, source->id, route_id, edge_type, props);
return false;
}
/* Classify a resolved call and emit the appropriate edge. */
@@ -808,7 +817,7 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
* match, #592/#606), the route/HTTP/ASYNC/CONFIG service classifications below
* still run — only the plain CALLS fall-through is skipped, so a fabricated
* project edge is dropped while every service edge stays main-identical. */
static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
static bool emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target,
const cbm_resolution_t *res, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count,
@@ -816,13 +825,11 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
CBMLanguage lang) {
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count,
route_mount, lang);
return;
return handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals,
imp_count, route_mount, lang);
}
if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) {
emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls);
return;
return emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls);
}
if (svc == CBM_SVC_CONFIG) {
char esc_c[CBM_SZ_256];
@@ -834,10 +841,10 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
esc_c, esc_k, res->confidence);
calls_emit_edge(ctx->gbuf, source->id, target->id, "CONFIGURES", props, sizeof(props),
call);
return;
return false;
}
if (suppress_plain_calls) {
return; /* weak TS/JS member-call match with an unresolved receiver (#606) */
return false; /* weak TS/JS member-call match with an unresolved receiver (#606) */
}
char esc_c2[CBM_SZ_256];
cbm_json_escape(esc_c2, sizeof(esc_c2), call->callee_name);
@@ -846,7 +853,7 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
"{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d}",
esc_c2, res->confidence, res->strategy ? res->strategy : "unknown",
res->candidate_count);
calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), call);
return calls_emit_edge(ctx->gbuf, source->id, target->id, "CALLS", props, sizeof(props), call);
}
/* Find source node for a call: enclosing function or file node. */
@@ -868,13 +875,206 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c
return src;
}
static CBMCallEvidence *calls_evidence_at(CBMCallEvidence *evidence, int index,
CBMCallEvidence *fallback) {
if (evidence) {
return &evidence[index];
}
return fallback;
}
static CBMCallEvidence *calls_evidence_new(cbm_pipeline_t *pipeline, const CBMFileResult *result) {
CBMCallEvidence *evidence =
cbm_calloc(CBM_MEM_CLASS_RESOLVE, (size_t)result->calls.count * sizeof(*evidence));
if (!evidence) {
cbm_pipeline_mark_unresolved_capture_failed(pipeline);
}
return evidence;
}
/* Report whether a service spelling handled this call, independently of
* whether its classified edge is CALLS and therefore covers the occurrence. */
static bool calls_emit_service_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source_node, cbm_svc_kind_t svc,
const cbm_resolution_t *resolved, CBMCallEvidence *evidence) {
if (svc != CBM_SVC_HTTP && svc != CBM_SVC_ASYNC) {
return false;
}
const char *url = call->first_string_arg;
if (!url || !url[0]) {
return false;
}
bool has_url_or_topic = url[0] == '/' || strstr(url, "://") != NULL ||
(svc == CBM_SVC_ASYNC && strlen(url) > PAIR_LEN);
if (!has_url_or_topic) {
return false;
}
cbm_resolution_t fallback = {.qualified_name = call->callee_name,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "service_pattern",
.candidate_count = 0};
const cbm_resolution_t *res = resolved ? resolved : &fallback;
evidence->calls_emitted |= emit_http_async_edge(ctx, call, source_node, NULL, res, svc, false);
return true;
}
/* Route registration counts as handled even when its edge is not CALLS. */
static bool calls_emit_route_call(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source_node, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount, CBMLanguage lang,
CBMCallEvidence *evidence) {
if (cbm_service_pattern_route_method(call->callee_name) == NULL || !call->first_string_arg ||
call->first_string_arg[0] != '/') {
return false;
}
evidence->calls_emitted |= handle_route_registration(
ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang);
return true;
}
/* An external callee can still register a route or address an HTTP/async service. */
static int calls_emit_unresolved_service(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source_node, const char *module_qn,
const char **imp_keys, const char **imp_vals,
int imp_count, const char *route_mount, CBMLanguage lang,
CBMCallEvidence *evidence) {
if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count,
route_mount, lang, evidence)) {
return SKIP_ONE;
}
cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name);
if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) {
svc = CBM_SVC_HTTP;
}
return calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence);
}
/* Apply the field-call policy after a registry target has been found. */
static const cbm_gbuf_node_t *calls_field_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const CBMResolvedCallArray *lsp_calls,
CBMLanguage lang, const char *rel,
const cbm_gbuf_node_t *source_node,
const cbm_gbuf_node_t *target_node,
bool arrow_bound, cbm_resolution_t *res) {
switch (cbm_call_onto_field_policy(lang, call->callee_name, target_node->label,
target_node->file_path)) {
case CBM_FIELD_CALL_DROP:
return NULL;
case CBM_FIELD_CALL_BY_OWNER:
/* An arrow call was already bound this way by registry resolution. */
if (!arrow_bound) {
target_node = cbm_pipeline_c_member_call_resolve(
lsp_calls, NULL, ctx->gbuf, ctx->registry, ctx->project_name,
call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel), res);
}
if (!target_node || source_node->id == target_node->id) {
return NULL;
}
break;
case CBM_FIELD_CALL_KEEP:
break;
}
return target_node;
}
static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const CBMResolvedCall *lsp, bool allow_tail, CBMLanguage lang,
const CBMImportArray *imports, const cbm_gbuf_node_t *source_node,
const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, const char *route_mount,
CBMCallEvidence *evidence) {
if (!lsp) {
return false;
}
bool exact_external_target =
call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn);
const cbm_gbuf_node_t *target_node =
exact_external_target ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail)
: cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail);
if (!target_node || source_node->id == target_node->id) {
return false;
}
cbm_resolution_t res = {0};
/* Keep the canonical graph QN, including project prefixes added by fallback. */
res.qualified_name = target_node->qualified_name;
res.confidence = lsp->confidence;
res.strategy = lsp->strategy;
res.candidate_count = PC_SINGLE_CANDIDATE_COUNT;
evidence->candidate_qn = target_node->qualified_name;
/* Synthetic builtins can still denote a spawn; keep the parallel pass policy. */
cbm_pipeline_spawn_t spawn;
if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) {
cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn);
return true;
}
evidence->calls_emitted |=
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
imp_vals, imp_count, false, route_mount, lang);
return true;
}
/* Vendor receivers and cross-language name guesses cannot bind project calls.
* Keep the registry policy shared with the parallel resolver. */
static cbm_resolution_t calls_registry_resolution(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count,
CBMLanguage lang, bool rust_external,
bool php_vendor_call) {
cbm_resolution_t res = {0};
if (!rust_external && !php_vendor_call) {
/* Python LEGB fallback first (MUST match pass_parallel.c). */
res =
cbm_registry_resolve_call_scoped(ctx->registry, call->callee_name, module_qn, imp_keys,
imp_vals, imp_count, lang, call->enclosing_func_qn);
/* Cross-language veto: a name-only guess never binds another
* language's symbol (a JS app.get must not become Python's
* builtins.dict.get). The vetoed answer becomes EMPTY here, before
* the fallbacks below, so the route/HTTP classification still runs.
* MUST match pass_parallel.c. */
if (cbm_registry_name_guess_vetoed(ctx->registry, lang, &res)) {
res = (cbm_resolution_t){0};
}
}
return res;
}
static int calls_emit_import_service(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source_node, const char *rel,
CBMLanguage lang, const CBMImportArray *imports,
cbm_external_import_index_t *ext_imports,
CBMCallEvidence *evidence) {
/* A callee an import binds to an external client library
* (`send` under `from paho.mqtt import publish as send`): spelled
* through its import, then classified. MUST match pass_parallel.c. */
char spelled[CBM_SZ_512];
cbm_svc_kind_t isvc =
cbm_pipeline_external_import_service(ext_imports, ctx->gbuf, ctx->project_name, rel, lang,
imports, call, spelled, sizeof(spelled));
if (isvc != CBM_SVC_NONE) {
CBMCall through = *call;
through.callee_name = spelled;
cbm_resolution_t imp_res = {.qualified_name = spelled,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "import_binding",
.candidate_count = 0};
evidence->calls_emitted |=
emit_http_async_edge(ctx, &through, source_node, NULL, &imp_res, isvc, false);
return SKIP_ONE;
}
return 0;
}
/* Resolve one call and emit the appropriate edge. Returns 1 if resolved, 0 if not. */
static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, CBMLanguage lang,
const CBMImportArray *imports, const char *route_mount,
const cbm_php_vendor_names_t *php_vendor,
cbm_external_import_index_t *ext_imports) {
cbm_external_import_index_t *ext_imports,
CBMCallEvidence *evidence) {
const CBMResolvedCallArray *lsp_calls = &result->resolved_calls;
const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn);
if (!source_node) {
@@ -892,35 +1092,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* Service/route classification below still runs. Mirrors pass_parallel.c. */
bool php_vendor_call = cbm_pipeline_php_vendor_call(php_vendor, result, call) ||
cbm_pipeline_lsp_external_receiver(lsp);
if (lsp) {
bool exact_external_target = call->requires_lsp_resolution &&
cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn);
const cbm_gbuf_node_t *target_node =
exact_external_target ? cbm_pipeline_lsp_target_node_strict(
ctx->gbuf, ctx->project_name, lsp->callee_qn, allow_tail)
: cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail);
if (target_node && source_node->id != target_node->id) {
cbm_resolution_t res = {0};
/* Use the gbuf node's QN so downstream edge props show the canonical
* project-qualified form even when fallback prefixed the project. */
res.qualified_name = target_node->qualified_name;
res.confidence = lsp->confidence;
res.strategy = lsp->strategy;
res.candidate_count = 1;
/* An LSP answer on a synthetic builtin (`<python-builtins>`) is
* not project code: a spawn spelling still spawns. The parallel
* pass keeps the LSP answer in `res` and asks the same question.
* MUST match pass_parallel.c. */
cbm_pipeline_spawn_t spawn;
if (cbm_pipeline_spawn_site(ctx->gbuf, lang, call, imports, &res, &spawn)) {
cbm_pipeline_emit_spawn(ctx->gbuf, source_node, call, &spawn);
return SKIP_ONE;
}
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
imp_vals, imp_count, false, route_mount, lang);
return SKIP_ONE;
}
if (calls_emit_lsp_target(ctx, call, lsp, allow_tail, lang, imports, source_node, module_qn,
imp_keys, imp_vals, imp_count, route_mount, evidence)) {
return SKIP_ONE;
}
/* Synthetic semantic candidates (currently implicit C++ operators) are
@@ -956,7 +1130,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "http_client_instance",
.candidate_count = 0};
emit_http_async_edge(ctx, &routed, source_node, NULL, &svc_res, CBM_SVC_HTTP, false);
evidence->calls_emitted |=
emit_http_async_edge(ctx, &routed, source_node, NULL, &svc_res, CBM_SVC_HTTP, false);
return SKIP_ONE;
}
/* A PHP call on a proven vendor class is classified by that class
@@ -965,19 +1140,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* LSP row with a graph target returned above. MUST match pass_parallel.c. */
cbm_svc_kind_t csvc;
const char *svc_name = cbm_pipeline_service_name(call, lsp, &csvc);
if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) {
const char *cu = call->first_string_arg;
bool chas_url = cu && cu[0] != '\0' &&
(cu[0] == '/' || strstr(cu, "://") != NULL ||
(csvc == CBM_SVC_ASYNC && strlen(cu) > PAIR_LEN));
if (chas_url) {
cbm_resolution_t svc_res = {.qualified_name = svc_name,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "service_pattern",
.candidate_count = 0};
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc, false);
return SKIP_ONE;
}
cbm_resolution_t svc_res = {.qualified_name = svc_name,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "service_pattern",
.candidate_count = 0};
if (calls_emit_service_call(ctx, call, source_node, csvc, &svc_res, evidence)) {
return SKIP_ONE;
}
/* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's
@@ -987,21 +1155,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* classify the call. MUST match pass_parallel.c. */
bool rust_external = lsp && cbm_pipeline_rust_external_target(
lang, lsp->strategy, lsp->callee_qn, ctx->project_name);
cbm_resolution_t res = {0};
if (!rust_external && !php_vendor_call) {
/* Python LEGB fallback first (MUST match pass_parallel.c). */
res =
cbm_registry_resolve_call_scoped(ctx->registry, call->callee_name, module_qn, imp_keys,
imp_vals, imp_count, lang, call->enclosing_func_qn);
/* Cross-language veto: a name-only guess never binds another
* language's symbol (a JS app.get must not become Python's
* builtins.dict.get). The vetoed answer becomes EMPTY here, before
* the fallbacks below, so the route/HTTP classification still runs.
* MUST match pass_parallel.c. */
if (cbm_registry_name_guess_vetoed(ctx->registry, lang, &res)) {
res = (cbm_resolution_t){0};
}
}
cbm_resolution_t res = calls_registry_resolution(
ctx, call, module_qn, imp_keys, imp_vals, imp_count, lang, rust_external, php_vendor_call);
/* `p->open(fd)`: the registry does not split a callee on the arrow, and a
* member is not a name it could resolve anyway. The object's type decides,
* exactly as for the dot call handled further down. MUST match
@@ -1042,48 +1197,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* apps. Classify by callee suffix + path-shaped first arg, exactly
* like the parallel path's callee_suffix fallback; without this the
* sequential path minted zero Route nodes for such files. */
if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg &&
call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals,
imp_count, route_mount, lang);
if (calls_emit_unresolved_service(ctx, call, source_node, module_qn, imp_keys, imp_vals,
imp_count, route_mount, lang, evidence)) {
return SKIP_ONE;
}
cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name);
if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) {
esvc = CBM_SVC_HTTP;
}
if (esvc == CBM_SVC_HTTP || esvc == CBM_SVC_ASYNC) {
const char *u = call->first_string_arg;
bool has_url_or_topic = u && u[0] != '\0' &&
(u[0] == '/' || strstr(u, "://") != NULL ||
(esvc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN));
if (has_url_or_topic) {
cbm_resolution_t svc_res = {.qualified_name = call->callee_name,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "service_pattern",
.candidate_count = 0};
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, esvc, false);
return SKIP_ONE;
}
}
/* A callee an import binds to an external client library
* (`send` under `from paho.mqtt import publish as send`): spelled
* through its import, then classified. MUST match pass_parallel.c. */
char spelled[CBM_SZ_512];
cbm_svc_kind_t isvc =
cbm_pipeline_external_import_service(ext_imports, ctx->gbuf, ctx->project_name, rel,
lang, imports, call, spelled, sizeof(spelled));
if (isvc != CBM_SVC_NONE) {
CBMCall through = *call;
through.callee_name = spelled;
cbm_resolution_t imp_res = {.qualified_name = spelled,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "import_binding",
.candidate_count = 0};
emit_http_async_edge(ctx, &through, source_node, NULL, &imp_res, isvc, false);
return SKIP_ONE;
}
return 0;
return calls_emit_import_service(ctx, call, source_node, rel, lang, imports, ext_imports,
evidence);
}
/* Perl call-graph noise guard (#476). Perl has no LSP resolver, so the
@@ -1183,16 +1302,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* the missing target must NOT drop the call — otherwise no HTTP_CALLS edge
* is written and cross-repo matching finds nothing (#523). Emit directly
* when the call carries a URL/topic first argument. */
cbm_svc_kind_t svc = cbm_service_pattern_match(res.qualified_name);
if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) {
const char *u = call->first_string_arg;
bool has_url_or_topic = u && u[0] != '\0' &&
(u[0] == '/' || strstr(u, "://") != NULL ||
(svc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN));
if (has_url_or_topic) {
emit_http_async_edge(ctx, call, source_node, NULL, &res, svc, false);
return SKIP_ONE;
}
if (calls_emit_service_call(ctx, call, source_node,
cbm_service_pattern_match(res.qualified_name), &res, evidence)) {
return SKIP_ONE;
}
const cbm_gbuf_node_t *target_node = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
@@ -1202,24 +1314,10 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
/* A call that resolved onto a struct Field: refused across languages and
* for a bare C call; a C/C++ member call takes the Field its object's type
* names instead of the one the member name happened to find. */
switch (cbm_call_onto_field_policy(lang, call->callee_name, target_node->label,
target_node->file_path)) {
case CBM_FIELD_CALL_DROP:
target_node = calls_field_target(ctx, call, lsp_calls, lang, rel, source_node, target_node,
arrow_bound, &res);
if (!target_node) {
return 0;
case CBM_FIELD_CALL_BY_OWNER:
/* An arrow call was already bound this way above. */
if (!arrow_bound) {
target_node = cbm_pipeline_c_member_call_resolve(
lsp_calls, NULL, ctx->gbuf, ctx->registry, ctx->project_name,
call->enclosing_func_qn, call->callee_name, cbm_c_member_rule_file(lang, rel),
&res);
}
if (!target_node || source_node->id == target_node->id) {
return 0;
}
break;
case CBM_FIELD_CALL_KEEP:
break;
}
/* #725: suffix_match is language-agnostic and will attach a Python
* Store.commit() call to a JS function named commit (or a Bash main
@@ -1232,16 +1330,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* unresolved-callee fallback above mints, and nothing else. Dropping
* the whole call lost every GET registration in a mixed-language repo
* while POST (no `post` to collide with) survived. */
if (cbm_service_pattern_route_method(call->callee_name) != NULL && call->first_string_arg &&
call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source_node, module_qn, imp_keys, imp_vals,
imp_count, route_mount, lang);
return SKIP_ONE;
}
return 0;
return calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals,
imp_count, route_mount, lang, evidence);
}
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
imp_count, drop_plain_call, route_mount, lang);
evidence->candidate_qn = target_node->qualified_name;
evidence->calls_emitted |=
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
imp_vals, imp_count, drop_plain_call, route_mount, lang);
return SKIP_ONE;
}
@@ -1392,12 +1487,15 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
}
if (result->calls.count == 0) {
cbm_pipeline_record_unresolved_calls(ctx->pipeline, rel, result, NULL);
if (result_owned) {
cbm_free_result(result);
}
continue;
}
CBMCallEvidence *evidence = calls_evidence_new(ctx->pipeline, result);
/* Build import map for this file */
const char **imp_keys = NULL;
const char **imp_vals = NULL;
@@ -1423,10 +1521,12 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
if (!call->callee_name) {
continue;
}
CBMCallEvidence fallback = {0};
total_calls++;
if (resolve_single_call(ctx, call, result, rel, module_qn, imp_keys, imp_vals,
imp_count, files[i].language, &result->imports, route_mount,
&php_vendor, &ext_imports)) {
&php_vendor, &ext_imports,
calls_evidence_at(evidence, c, &fallback))) {
resolved++;
} else {
unresolved++;
@@ -1434,6 +1534,8 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
}
cbm_pipeline_external_import_index_free(&ext_imports);
cbm_pipeline_record_unresolved_calls(ctx->pipeline, rel, result, evidence);
cbm_free(CBM_MEM_CLASS_RESOLVE, evidence);
free(module_qn);
cbm_pipeline_php_vendor_names_free(&php_vendor);
free_import_map(imp_keys, imp_vals, imp_count);
+101 -47
View File
@@ -2325,10 +2325,11 @@ void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool
}
/* Build props JSON, append args, close brace, emit edge. */
static void finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id,
static bool finalize_and_emit(cbm_gbuf_t *gbuf, int64_t src_id, int64_t tgt_id,
const char *edge_type, char *props, int n, const CBMCall *call) {
cbm_pipeline_close_call_props(props, n, call, strcmp(edge_type, "CALLS") == 0);
cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props);
return cbm_gbuf_insert_edge(gbuf, src_id, tgt_id, edge_type, props) > 0 &&
strcmp(edge_type, "CALLS") == 0;
}
/* Build Route node QN and properties for HTTP/async service edges. */
@@ -2404,13 +2405,13 @@ static void emit_config_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
}
/* Emit normal CALLS edge. */
static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
static bool emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const cbm_gbuf_node_t *target, const CBMCall *call,
const cbm_resolution_t *res) {
/* A CALLS edge needs a real target; the HTTP/ASYNC external-client bypass
* (#523) can reach emit_service_edge with target==NULL, so guard the deref. */
if (!target) {
return;
return false;
}
char esc_c[CBM_SZ_256];
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
@@ -2419,14 +2420,14 @@ static void emit_normal_calls_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sour
"{\"callee\":\"%s\",\"confidence\":%.2f,\"strategy\":\"%s\",\"candidates\":%d",
esc_c, res->confidence, res->strategy ? res->strategy : "unknown",
res->candidate_count);
finalize_and_emit(gbuf, source->id, target->id, "CALLS", props, n, call);
return finalize_and_emit(gbuf, source->id, target->id, "CALLS", props, n, call);
}
/* Classify a resolved call by library identity and emit the appropriate edge. */
/* Create Route node + CALLS + HANDLES edges for a route registration call.
* route_mount is the framework mount of the registering file ("/api" for a
* Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */
static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
static bool emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const CBMCall *call, const char *route_path,
const char *handler_ref, const char *module_qn,
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
@@ -2450,7 +2451,7 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou
snprintf(props, sizeof(props),
"{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"route_registration\"}", esc_cn,
esc_rp);
cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props);
bool emitted = cbm_gbuf_insert_edge(gbuf, source->id, rid, "CALLS", props) > 0;
if (handler_ref && handler_ref[0] != '\0') {
/* Python: a handler an app factory registers is usually its local def
* (`app.add_url_rule("/", "index", index)` inside create_app); only the
@@ -2476,6 +2477,7 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou
}
}
}
return emitted;
}
/* Reject regex metacharacters, spaces, double-slashes in URL candidates. */
@@ -2787,7 +2789,7 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons
* and the HTTP/ASYNC/gRPC/GraphQL/tRPC/CONFIG/route branches are unaffected, so
* a verb-suffix HTTP client (api.patch('/x')), broker, or route registration
* keeps its edge; only the fabricated project CALLS edge is dropped. */
static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
static bool emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const cbm_gbuf_node_t *target, const CBMCall *call,
const cbm_resolution_t *res, const char *module_qn,
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
@@ -2819,10 +2821,9 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const char *handler_ref = NULL;
const char *route_path = find_route_path_in_args(call, &handler_ref);
if (route_path) {
emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
registry, main_gbuf, imp_keys, imp_vals, imp_count, route_mount,
lang);
return;
return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
registry, main_gbuf, imp_keys, imp_vals, imp_count,
route_mount, lang);
}
/* No path found — fall through to normal CALLS edge */
}
@@ -2832,6 +2833,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
/* Set when this call already has its HTTP_CALLS edge from the service
* patterns (typed with the verb its callee names). */
bool http_edge_emitted = false;
bool calls_emitted = false;
if ((svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) && (has_url || has_topic)) {
emit_http_async_service_edge(gbuf, source, call, res, svc, arg);
@@ -2845,7 +2847,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
} else if (svc == CBM_SVC_CONFIG) {
emit_config_edge(gbuf, source, target, call, res, arg);
} else if (!suppress_plain_calls) {
emit_normal_calls_edge(gbuf, source, target, call, res);
calls_emitted = emit_normal_calls_edge(gbuf, source, target, call, res);
}
/* The arg-URL heuristic is for calls no service pattern knows (a local
@@ -2855,6 +2857,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
if (!http_edge_emitted) {
detect_url_in_args(gbuf, source, call, svc, pp_client_navigation_lang(lang));
}
return calls_emitted;
}
/* The #725 guard refuses a suffix_match binding across a language boundary,
@@ -2867,24 +2870,26 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
* CALLS edge to the refused target and no URL-argument scan. Dropping the
* whole call lost every GET registration in a mixed-language repo while POST
* (no `post` to collide with) survived. Mirrors pass_calls.c. */
static void emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
static bool emit_xlang_refused_route(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const CBMCall *call, const char *module_qn,
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount, CBMLanguage lang) {
if (cbm_service_pattern_route_method(call->callee_name) == NULL) {
return;
return false;
}
cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name);
if (svc != CBM_SVC_NONE && svc != CBM_SVC_ROUTE_REG) {
return;
return false;
}
const char *handler_ref = NULL;
const char *route_path = find_route_path_in_args(call, &handler_ref);
if (route_path) {
emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn, registry,
main_gbuf, imp_keys, imp_vals, imp_count, route_mount, lang);
return emit_route_registration(gbuf, source, call, route_path, handler_ref, module_qn,
registry, main_gbuf, imp_keys, imp_vals, imp_count,
route_mount, lang);
}
return false;
}
/* Find the source node for an edge: enclosing function or file node. */
@@ -3137,11 +3142,32 @@ static const CBMResolvedCall *lsp_idx_lookup(const CBMHashTable *index, const CB
: NULL;
}
static CBMCallEvidence *resolve_evidence_at(CBMCallEvidence *evidence, int index,
CBMCallEvidence *fallback) {
if (evidence) {
return &evidence[index];
}
return fallback;
}
static CBMCallEvidence *resolve_evidence_new(const resolve_ctx_t *rc, const CBMFileResult *result) {
if (result->calls.count == 0) {
return NULL;
}
CBMCallEvidence *evidence =
cbm_calloc(CBM_MEM_CLASS_RESOLVE, (size_t)result->calls.count * sizeof(*evidence));
if (!evidence) {
cbm_pipeline_mark_unresolved_capture_failed(rc->pctx ? rc->pctx->pipeline : NULL);
}
return evidence;
}
/* Resolve calls for one file and emit CALLS/HTTP_CALLS/ASYNC_CALLS edges. */
static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, CBMLanguage lang,
const cbm_php_vendor_names_t *php_vendor) {
const cbm_php_vendor_names_t *php_vendor,
CBMCallEvidence *evidence) {
/* Framework mount of this file's routes (Laravel 11+ withRouting, #1146). */
char route_mount[CBM_SZ_128];
cbm_laravel_file_route_mount(rc->repo_path, rel, lang, result, route_mount,
@@ -3220,7 +3246,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
bool field_index_built = false;
bool field_index_ready = false;
for (int c = 0; c < result->calls.count; c++) {
CBMCall *call = &result->calls.items[c];
const CBMCall *call = &result->calls.items[c];
CBMCallEvidence fallback = {0};
CBMCallEvidence *site_evidence = resolve_evidence_at(evidence, c, &fallback);
if (!call->callee_name) {
continue;
}
@@ -3476,9 +3504,10 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
cbm_resolution_t svc_res = {.qualified_name = svc_name,
.confidence = PP_HALF_CONF,
.strategy = "service_pattern"};
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, false, route_mount, lang);
site_evidence->calls_emitted |=
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, false, route_mount, lang);
continue;
}
}
@@ -3503,9 +3532,10 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
* route path (`cache.get(key)`) would be a self-loop; that holds
* for #2053's LSP-external Rust calls too. Suppress it always;
* route/HTTP/service classification is unchanged. */
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, true, route_mount, lang);
site_evidence->calls_emitted |=
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, true, route_mount, lang);
} else if (cbm_service_pattern_is_global_fetch(call->callee_name)) {
/* Native `fetch()` (#856): only the global API once resolution
* has failed to find a local/imported `fetch`. Call the low-level
@@ -3583,9 +3613,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
/* #725: same guard as pass_calls.c — do not emit a suffix_match
* CALLS edge across a language boundary. A route registration
* behind the refused binding still gets its Route. */
emit_xlang_refused_route(ws->local_edge_buf, source_node, call, module_qn, rc->registry,
rc->main_gbuf, imp_keys, imp_vals, imp_count, route_mount,
lang);
site_evidence->calls_emitted |= emit_xlang_refused_route(
ws->local_edge_buf, source_node, call, module_qn, rc->registry, rc->main_gbuf,
imp_keys, imp_vals, imp_count, route_mount, lang);
continue;
}
if (!target_node || source_node->id == target_node->id) {
@@ -3601,18 +3631,19 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
(u[0] == '/' || strstr(u, "://") != NULL ||
(psvc == CBM_SVC_ASYNC && strlen(u) > PP_ESC_SPACE));
if (url_or_topic) {
emit_service_edge(ws->local_edge_buf, source_node, NULL, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
false, route_mount, lang);
site_evidence->calls_emitted |= emit_service_edge(
ws->local_edge_buf, source_node, NULL, call, &res, module_qn, rc->registry,
rc->main_gbuf, imp_keys, imp_vals, imp_count, false, route_mount, lang);
ws->calls_resolved++;
}
}
continue;
}
site_evidence->candidate_qn = target_node->qualified_name;
_rc_t0 = extract_now_ns();
emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
drop_plain_call, route_mount, lang);
site_evidence->calls_emitted |= emit_service_edge(
ws->local_edge_buf, source_node, target_node, call, &res, module_qn, rc->registry,
rc->main_gbuf, imp_keys, imp_vals, imp_count, drop_plain_call, route_mount, lang);
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
memory_order_relaxed);
ws->calls_resolved++;
@@ -4140,6 +4171,30 @@ static bool pp_has_pending_lsp_site(const CBMFileResult *result) {
return false;
}
static CBMFileResult *resolve_load_result(resolve_ctx_t *rc, resolve_worker_state_t *ws,
int file_idx) {
if (ws->loaded) {
cbm_free_result(ws->loaded);
ws->loaded = NULL;
}
CBMFileResult *result = rc->result_cache[file_idx];
if (!result && rc->pctx && rc->pctx->spill && cbm_result_spill_has(rc->pctx->spill, file_idx)) {
result = cbm_result_spill_load(rc->pctx->spill, file_idx);
ws->loaded = result;
(void)cbm_pipeline_test_extraction_ok(rc->pctx, rc->files[file_idx].language, result);
if (!result) {
cbm_pipeline_mark_unresolved_capture_failed(rc->pctx->pipeline);
}
}
return result;
}
static void resolve_capture_coverage(const resolve_ctx_t *rc, const char *rel,
const CBMFileResult *result, const CBMCallEvidence *evidence) {
cbm_pipeline_record_unresolved_calls(rc->pctx ? rc->pctx->pipeline : NULL, rel, result,
evidence);
}
/* A C member access binds only through a field-owner row. The per-file walk
* publishes owners for the structs its own file declares; a struct from a
* header is known only to the cross-file pass. A member access that still has
@@ -4222,17 +4277,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
uint64_t _loop_t0 = extract_now_ns();
if (ws->loaded) {
cbm_free_result(ws->loaded);
ws->loaded = NULL;
}
CBMFileResult *result = rc->result_cache[file_idx];
if (!result && rc->pctx && rc->pctx->spill &&
cbm_result_spill_has(rc->pctx->spill, file_idx)) {
result = cbm_result_spill_load(rc->pctx->spill, file_idx);
ws->loaded = result;
(void)cbm_pipeline_test_extraction_ok(rc->pctx, rc->files[file_idx].language, result);
}
CBMFileResult *result = resolve_load_result(rc, ws, file_idx);
if (!result) {
atomic_fetch_add_explicit(&rc->time_ns_total_loop, extract_now_ns() - _loop_t0,
memory_order_relaxed);
@@ -4245,6 +4290,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
CBMLanguage lang = rc->files[file_idx].language;
const char *rel = rc->files[file_idx].rel_path;
CBMCallEvidence *evidence = NULL;
/* Skip cross-LSP for machine-generated files — they're huge (10k-
* 70k lines for k8s protobuf/openapi), have low semantic value for
@@ -4302,6 +4348,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
if (result->calls.count == 0 && result->usages.count == 0 && result->throws.count == 0 &&
result->rw.count == 0 && result->defs.count == 0 && result->impl_traits.count == 0 &&
!cross_lsp_eligible) {
resolve_capture_coverage(rc, rel, result, NULL);
continue;
}
@@ -4460,10 +4507,12 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
/* Per-sub-phase wall-clock so we can attribute the dominant cost. */
uint64_t _ph_t0;
/* Cross-LSP may append synthetic calls; their positions are now final. */
evidence = resolve_evidence_new(rc, result);
/* ── CALLS resolution ──────────────────────────────────── */
_ph_t0 = extract_now_ns();
resolve_file_calls(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang,
&php_vendor);
&php_vendor, evidence);
atomic_fetch_add_explicit(&rc->time_ns_calls, extract_now_ns() - _ph_t0,
memory_order_relaxed);
@@ -4499,6 +4548,11 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
}
resolve_file_cleanup:
/* Capture the resolved file and side evidence before releasing either,
* including files loaded from the spill store. */
resolve_capture_coverage(rc, rel, result, evidence);
cbm_free(CBM_MEM_CLASS_RESOLVE, evidence);
cbm_registry_reach_cache_end();
cbm_registry_import_map_cache_end();
cbm_registry_resolve_cache_end();
+423 -1
View File
@@ -21,6 +21,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 7 };
#include "pipeline/pipeline_internal.h"
#include "pipeline/doc_links.h"
#include "pipeline/lsp_surface.h"
#include "pipeline/lsp_resolve.h"
#include "pipeline/pass_lsp_cross.h"
#include "pipeline/pass_ensemble_routing.h"
#include "pipeline/worker_pool.h"
@@ -43,7 +44,13 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 7 };
#include "foundation/mem.h"
#include "foundation/mem_core.h"
#include "result_spill.h"
#include "cbm.h"
#include "foundation/secure_random.h"
/* C11 guarantees stdbool.h; keep yyjson's Boolean types aligned with ours. */
#ifndef YYJSON_HAS_STDBOOL_H
#define YYJSON_HAS_STDBOOL_H 1
#endif
#include "yyjson/yyjson.h"
#include <ctype.h>
#include <errno.h>
@@ -284,6 +291,13 @@ struct cbm_pipeline {
cbm_lsp_surface_row_t *surface_rows;
int surface_row_count;
/* Per-file unresolved invocation diagnostics, published as coverage rows. */
cbm_coverage_row_t *unresolved_rows;
int unresolved_count;
int unresolved_capacity;
atomic_bool unresolved_capture_failed;
cbm_mutex_t unresolved_mutex; /* one append per file, after parallel resolution */
/* This run's doc_link_unresolved rows (doc_links.h), handed over by the
* resolve phase; doc_links_ran stays false until a doc-link phase did. */
cbm_doc_link_row_t *doc_link_rows;
@@ -657,6 +671,8 @@ static cbm_pipeline_frozen_status_t frozen_allocate_pipeline(
cbm_pipeline_t *p = calloc(1, sizeof(*p));
if (!p)
return CBM_PIPELINE_FROZEN_OOM;
atomic_init(&p->unresolved_capture_failed, false);
cbm_mutex_init(&p->unresolved_mutex);
owner->pipeline = p;
p->frozen = owner;
p->repo_path = strdup(in->source_root);
@@ -880,6 +896,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path,
p->committed_nodes = -1;
p->committed_edges = -1;
atomic_init(&p->cancelled_storage, 0);
atomic_init(&p->unresolved_capture_failed, false);
cbm_mutex_init(&p->unresolved_mutex);
p->cancelled = &p->cancelled_storage;
return p;
@@ -978,6 +996,392 @@ void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *row
p->surface_row_count = count;
}
static void *unresolved_json_alloc(void *ctx, size_t size) {
(void)ctx;
return cbm_alloc(CBM_MEM_CLASS_RESOLVE, size);
}
static void *unresolved_json_realloc(void *ctx, void *ptr, size_t old_size, size_t size) {
(void)ctx;
(void)old_size;
return cbm_realloc(CBM_MEM_CLASS_RESOLVE, ptr, size);
}
static void unresolved_json_free(void *ctx, void *ptr) {
(void)ctx;
cbm_free(CBM_MEM_CLASS_RESOLVE, ptr);
}
enum {
UNRESOLVED_KEY_OVERHEAD = 128,
UNRESOLVED_INITIAL_ROWS = 64,
UNRESOLVED_ROW_GROWTH = 2,
UNRESOLVED_MAX_SITES = 1000,
UNRESOLVED_MAX_JSON_BYTES = 128 * 1024,
UNRESOLVED_SITE_OVERHEAD = 256,
UNRESOLVED_TRUNCATION_RESERVE = 32,
UNRESOLVED_JSON_ARRAY_BYTES = 2,
UNRESOLVED_JSON_RAW_CHAR_BYTES = 1,
UNRESOLVED_JSON_ESCAPE_BYTES = 2,
UNRESOLVED_JSON_CONTROL_ESCAPE_BYTES = 6,
UNRESOLVED_JSON_CONTROL_BOUNDARY = 0x20,
UNRESOLVED_JSON_OVERSIZE = UNRESOLVED_MAX_JSON_BYTES + 1,
UNRESOLVED_NO_CARRIER = -1,
UNRESOLVED_AMBIGUOUS_CARRIER = -2
};
static char *unresolved_site_key(const char *caller, const char *leaf, uint32_t start, uint32_t end,
CBMSourceOrigin origin) {
size_t cn = strlen(caller);
size_t ln = strlen(leaf);
size_t cap = cn + ln + UNRESOLVED_KEY_OVERHEAD;
char *key = cbm_alloc(CBM_MEM_CLASS_RESOLVE, cap);
if (key) {
(void)snprintf(key, cap, "%zu:%s|%zu:%s|%u:%u:%u", cn, caller, ln, leaf, start, end,
(unsigned)origin);
}
return key;
}
typedef struct {
int call_index;
} unresolved_carrier_t;
typedef struct {
yyjson_mut_doc *doc;
yyjson_mut_val *sites;
CBMHashTable *carriers;
CBMHashTable *seen;
const CBMFileResult *result;
const CBMCallEvidence *evidence;
const CBMResolvedCall *selected[UNRESOLVED_MAX_SITES];
int selected_count;
size_t json_bytes;
bool truncated;
} unresolved_capture_t;
static void unresolved_carrier_free(const char *key, void *value, void *ctx) {
(void)ctx;
cbm_free(CBM_MEM_CLASS_RESOLVE, (void *)key);
cbm_free(CBM_MEM_CLASS_RESOLVE, value);
}
static void unresolved_key_free(const char *key, void *value, void *ctx) {
(void)value;
(void)ctx;
cbm_free(CBM_MEM_CLASS_RESOLVE, (void *)key);
}
static void unresolved_capture_free(unresolved_capture_t *capture) {
if (capture->carriers) {
cbm_ht_foreach(capture->carriers, unresolved_carrier_free, NULL);
cbm_ht_free(capture->carriers);
}
if (capture->seen) {
cbm_ht_foreach(capture->seen, unresolved_key_free, NULL);
cbm_ht_free(capture->seen);
}
if (capture->doc) {
yyjson_mut_doc_free(capture->doc);
}
}
static bool unresolved_call_is_missing(const CBMResolvedCall *rc) {
return rc->kind == CBM_RESOLVED_INVOCATION && rc->strategy &&
strcmp(rc->strategy, "lsp_unresolved") == 0 && rc->caller_qn && rc->callee_qn;
}
static bool unresolved_has_missing_calls(const CBMFileResult *result) {
for (int i = 0; i < result->resolved_calls.count; i++) {
if (unresolved_call_is_missing(&result->resolved_calls.items[i])) {
return true;
}
}
return false;
}
static size_t unresolved_json_char_bytes(unsigned char ch) {
if (ch < UNRESOLVED_JSON_CONTROL_BOUNDARY) {
return UNRESOLVED_JSON_CONTROL_ESCAPE_BYTES;
}
if (ch == '"' || ch == '\\') {
return UNRESOLVED_JSON_ESCAPE_BYTES;
}
return UNRESOLVED_JSON_RAW_CHAR_BYTES;
}
/* Stop at the budget even for one enormous string. Reserve six bytes for each
* escaped control character; quotes and backslashes use two bytes in JSON. */
static size_t unresolved_string_json_bytes(const char *text) {
size_t bytes = 0;
for (const unsigned char *p = (const unsigned char *)text; p && *p; p++) {
size_t width = unresolved_json_char_bytes(*p);
if (bytes > UNRESOLVED_MAX_JSON_BYTES - width) {
return UNRESOLVED_JSON_OVERSIZE;
}
bytes += width;
}
return bytes;
}
static size_t unresolved_site_json_bytes(const CBMResolvedCall *rc, const char *caller,
const char *leaf, const char *candidate) {
const char *fields[] = {caller, leaf, rc->reason ? rc->reason : "unresolved", candidate};
size_t bytes = UNRESOLVED_SITE_OVERHEAD;
for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) {
size_t field_bytes = unresolved_string_json_bytes(fields[i]);
if (field_bytes > UNRESOLVED_MAX_JSON_BYTES - bytes) {
return UNRESOLVED_JSON_OVERSIZE;
}
bytes += field_bytes;
}
return bytes;
}
/* Hash only the bounded diagnostic subset. Hashing every extracted call
* would retain unbounded side state. */
static bool unresolved_select_diagnostics(unresolved_capture_t *capture) {
size_t selected_bytes = UNRESOLVED_JSON_ARRAY_BYTES + UNRESOLVED_TRUNCATION_RESERVE;
const CBMFileResult *result = capture->result;
for (int i = 0; i < result->resolved_calls.count; i++) {
const CBMResolvedCall *rc = &result->resolved_calls.items[i];
if (!unresolved_call_is_missing(rc)) {
continue;
}
const char *leaf = cbm_lsp_bare_segment(rc->callee_qn);
size_t site_bytes = unresolved_site_json_bytes(rc, rc->caller_qn, leaf, NULL);
if (capture->selected_count == UNRESOLVED_MAX_SITES ||
site_bytes > UNRESOLVED_MAX_JSON_BYTES - selected_bytes) {
capture->truncated = true;
break;
}
selected_bytes += site_bytes;
capture->selected[capture->selected_count++] = rc;
char *key = unresolved_site_key("", leaf, rc->site_start_byte, rc->site_end_byte,
rc->source_origin);
if (!key) {
return false;
}
if (cbm_ht_has(capture->carriers, key)) {
cbm_free(CBM_MEM_CLASS_RESOLVE, key);
continue;
}
unresolved_carrier_t *carrier = cbm_alloc(CBM_MEM_CLASS_RESOLVE, sizeof(*carrier));
if (!carrier) {
cbm_free(CBM_MEM_CLASS_RESOLVE, key);
return false;
}
carrier->call_index = UNRESOLVED_NO_CARRIER;
cbm_ht_set(capture->carriers, key, carrier);
if (!cbm_ht_has(capture->carriers, key)) {
cbm_free(CBM_MEM_CLASS_RESOLVE, key);
cbm_free(CBM_MEM_CLASS_RESOLVE, carrier);
return false;
}
}
return true;
}
static bool unresolved_build_carriers(unresolved_capture_t *capture) {
if (!unresolved_select_diagnostics(capture)) {
return false;
}
const CBMFileResult *result = capture->result;
/* Caller names in the TS LSP walk can denote an outer factory. Join on
* span, leaf and origin; the extractor owns the enclosing function name. */
for (int i = 0; i < result->calls.count && capture->selected_count > 0; i++) {
const CBMCall *call = &result->calls.items[i];
if (!call->callee_name || call->site_end_byte <= call->site_start_byte) {
continue;
}
const char *leaf = cbm_lsp_bare_segment(call->callee_name);
if (unresolved_string_json_bytes(leaf) > UNRESOLVED_MAX_JSON_BYTES) {
continue; /* No bounded diagnostic key can match this leaf. */
}
char *key = unresolved_site_key("", leaf, call->site_start_byte, call->site_end_byte,
call->source_origin);
if (!key) {
return false;
}
unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, key);
cbm_free(CBM_MEM_CLASS_RESOLVE, key);
if (carrier) {
/* An ambiguous occurrence must never erase a diagnostic. */
carrier->call_index =
carrier->call_index == UNRESOLVED_NO_CARRIER ? i : UNRESOLVED_AMBIGUOUS_CARRIER;
}
}
return true;
}
static bool unresolved_append_site(unresolved_capture_t *capture, const CBMResolvedCall *rc,
const CBMCall *call, const CBMCallEvidence *evidence,
const char *caller, const char *leaf) {
const char *candidate = evidence ? evidence->candidate_qn : NULL;
size_t site_bytes = unresolved_site_json_bytes(rc, caller, leaf, candidate);
if (site_bytes > UNRESOLVED_MAX_JSON_BYTES - capture->json_bytes) {
capture->truncated = true;
return true;
}
yyjson_mut_doc *doc = capture->doc;
yyjson_mut_val *site = yyjson_mut_obj(doc);
bool ok =
site && yyjson_mut_obj_add_strcpy(doc, site, "caller", caller) &&
yyjson_mut_obj_add_strcpy(doc, site, "leaf", leaf) &&
yyjson_mut_obj_add_uint(doc, site, "start_byte", rc->site_start_byte) &&
yyjson_mut_obj_add_uint(doc, site, "end_byte", rc->site_end_byte) &&
yyjson_mut_obj_add_uint(doc, site, "source_origin", rc->source_origin) &&
yyjson_mut_obj_add_strcpy(doc, site, "reason", rc->reason ? rc->reason : "unresolved");
if (ok && call && call->start_line > 0) {
ok = yyjson_mut_obj_add_int(doc, site, "line", call->start_line);
}
if (ok && candidate) {
ok = yyjson_mut_obj_add_strcpy(doc, site, "candidate", candidate);
}
capture->json_bytes += site_bytes;
return ok && yyjson_mut_arr_add_val(capture->sites, site);
}
static bool unresolved_collect_site(unresolved_capture_t *capture, const CBMResolvedCall *rc) {
const char *leaf = cbm_lsp_bare_segment(rc->callee_qn);
char *lookup =
unresolved_site_key("", leaf, rc->site_start_byte, rc->site_end_byte, rc->source_origin);
if (!lookup) {
return false;
}
const unresolved_carrier_t *carrier = cbm_ht_get(capture->carriers, lookup);
int call_index = carrier ? carrier->call_index : UNRESOLVED_NO_CARRIER;
const CBMCall *call = call_index >= 0 ? &capture->result->calls.items[call_index] : NULL;
const CBMCallEvidence *evidence =
call_index >= 0 && capture->evidence ? &capture->evidence[call_index] : NULL;
cbm_free(CBM_MEM_CLASS_RESOLVE, lookup);
/* Suppress only this exact occurrence's real CALLS edge, including dedup.
* HTTP/ASYNC/CONFIGURES/SPAWNS and a candidate alone never suppress it. */
if (evidence && evidence->calls_emitted) {
return true;
}
const char *caller = call && call->enclosing_func_qn ? call->enclosing_func_qn : rc->caller_qn;
/* Bound both the retained dedup key and copied JSON strings. */
size_t site_bytes =
unresolved_site_json_bytes(rc, caller, leaf, evidence ? evidence->candidate_qn : NULL);
if (site_bytes > UNRESOLVED_MAX_JSON_BYTES - capture->json_bytes) {
capture->truncated = true;
return true;
}
char *key = unresolved_site_key(caller, leaf, rc->site_start_byte, rc->site_end_byte,
rc->source_origin);
if (!key) {
return false;
}
if (cbm_ht_has(capture->seen, key)) {
cbm_free(CBM_MEM_CLASS_RESOLVE, key);
return true;
}
cbm_ht_set(capture->seen, key, key);
if (!cbm_ht_has(capture->seen, key)) {
cbm_free(CBM_MEM_CLASS_RESOLVE, key);
return false;
}
return unresolved_append_site(capture, rc, call, evidence, caller, leaf);
}
static bool unresolved_collect_sites(unresolved_capture_t *capture) {
for (int i = 0; i < capture->selected_count; i++) {
if (!unresolved_collect_site(capture, capture->selected[i])) {
return false;
}
}
if (capture->truncated) {
yyjson_mut_val *marker = yyjson_mut_obj(capture->doc);
return marker && yyjson_mut_obj_add_bool(capture->doc, marker, "truncated", true) &&
yyjson_mut_arr_add_val(capture->sites, marker);
}
return true;
}
/* Caller holds unresolved_mutex. Ownership transfers only on success. */
static bool unresolved_append_row(cbm_pipeline_t *p, const char *path, const char *detail) {
if (!detail || !path || p->unresolved_capture_failed) {
return false;
}
if (p->unresolved_count == p->unresolved_capacity) {
int capacity = p->unresolved_capacity ? p->unresolved_capacity * UNRESOLVED_ROW_GROWTH
: UNRESOLVED_INITIAL_ROWS;
cbm_coverage_row_t *rows = cbm_realloc(CBM_MEM_CLASS_RESOLVE, p->unresolved_rows,
(size_t)capacity * sizeof(*rows));
if (!rows) {
return false;
}
p->unresolved_rows = rows;
p->unresolved_capacity = capacity;
}
p->unresolved_rows[p->unresolved_count++] =
(cbm_coverage_row_t){.rel_path = path, .kind = "unresolved_calls", .detail = detail};
return true;
}
void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path,
const CBMFileResult *result,
const CBMCallEvidence *evidence) {
if (!p || !rel_path || !result || p->unresolved_capture_failed) {
return;
}
if (!unresolved_has_missing_calls(result)) {
return;
}
yyjson_alc allocator = {.malloc = unresolved_json_alloc,
.realloc = unresolved_json_realloc,
.free = unresolved_json_free};
unresolved_capture_t capture = {.doc = yyjson_mut_doc_new(&allocator),
.carriers = cbm_ht_create_in(CBM_MEM_CLASS_RESOLVE, 0),
.seen = cbm_ht_create_in(CBM_MEM_CLASS_RESOLVE, 0),
.result = result,
.evidence = evidence,
.json_bytes = UNRESOLVED_JSON_ARRAY_BYTES +
UNRESOLVED_TRUNCATION_RESERVE};
capture.sites = capture.doc ? yyjson_mut_arr(capture.doc) : NULL;
bool ok = capture.doc && capture.carriers && capture.seen && capture.sites;
if (capture.doc) {
yyjson_mut_doc_set_root(capture.doc, capture.sites);
}
ok = ok && unresolved_build_carriers(&capture) && unresolved_collect_sites(&capture);
if (ok && yyjson_mut_arr_size(capture.sites) > 0) {
char *detail = yyjson_mut_write_opts(capture.doc, 0, &allocator, NULL, NULL);
char *path = cbm_mem_strdup(CBM_MEM_CLASS_RESOLVE, rel_path);
/* The estimate reserves overhead and escaping. Enforce the serialized
* limit as well, before any detail is transferred to persistence. */
ok = detail && strlen(detail) <= UNRESOLVED_MAX_JSON_BYTES;
if (ok) {
cbm_mutex_lock(&p->unresolved_mutex);
ok = unresolved_append_row(p, path, detail);
cbm_mutex_unlock(&p->unresolved_mutex);
}
if (!ok) {
cbm_free(CBM_MEM_CLASS_RESOLVE, detail);
cbm_free(CBM_MEM_CLASS_RESOLVE, path);
}
}
if (!ok) {
p->unresolved_capture_failed = true;
}
unresolved_capture_free(&capture);
}
void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p) {
if (p)
p->unresolved_capture_failed = true;
}
void cbm_pipeline_get_unresolved_calls(cbm_pipeline_t *p, cbm_coverage_row_t **rows, int *count,
bool *complete) {
if (rows)
*rows = p ? p->unresolved_rows : NULL;
if (count)
*count = p ? p->unresolved_count : 0;
if (complete)
*complete = p && !p->unresolved_capture_failed;
}
void cbm_pipeline_set_doc_candidates(cbm_pipeline_t *p, cbm_doc_candidate_t *rows, int count) {
if (!p) {
cbm_store_doc_candidates_free(rows, count);
@@ -1074,6 +1478,13 @@ void cbm_pipeline_free(cbm_pipeline_t *p) {
cbm_store_free_lsp_surfaces(p->surface_rows, p->surface_row_count);
p->surface_rows = NULL;
p->surface_row_count = 0;
for (int i = 0; i < p->unresolved_count; i++) {
cbm_free(CBM_MEM_CLASS_RESOLVE, (char *)p->unresolved_rows[i].rel_path);
cbm_free(CBM_MEM_CLASS_RESOLVE, (char *)p->unresolved_rows[i].detail);
}
cbm_free(CBM_MEM_CLASS_RESOLVE, p->unresolved_rows);
cbm_mutex_destroy(&p->unresolved_mutex);
pipeline_reset_doc_links(p);
cbm_store_doc_candidates_free(p->doc_candidates, p->doc_candidate_count);
p->doc_candidates = NULL;
@@ -3599,6 +4010,11 @@ int cbm_pipeline_finalize_staged_generation(char *path, const char *destination,
return finalize_staged_impl(path, destination, cancelled, healthy, NULL);
}
static bool pipeline_unresolved_capture_complete(const cbm_pipeline_t *p,
bool coverage_rows_available) {
return coverage_rows_available && !p->unresolved_capture_failed;
}
/* Dump graph to SQLite and persist file hashes for incremental indexing. */
static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *baseline_manifest,
int baseline_count, struct timespec *t) {
@@ -3656,7 +4072,8 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas
return CBM_PIPELINE_ABORT_PRESERVE_DB;
}
int cov_total = p->file_errors_count + p->excluded_count + p->ignored_count;
int cov_total =
p->file_errors_count + p->excluded_count + p->ignored_count + p->unresolved_count;
cbm_coverage_row_t *cov = NULL;
int cov_count = 0;
bool coverage_rows_available = cov_total == 0;
@@ -3664,6 +4081,9 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas
cov = malloc((size_t)cov_total * sizeof(*cov));
if (cov) {
coverage_rows_available = true;
for (int i = 0; i < p->unresolved_count; i++) {
cov[cov_count++] = p->unresolved_rows[i];
}
for (int i = 0; i < p->file_errors_count; i++) {
cov[cov_count++] = (cbm_coverage_row_t){.rel_path = p->file_errors[i].path,
.kind = p->file_errors[i].phase,
@@ -3702,6 +4122,8 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_hash_t *bas
.ignored_files_total = p->ignored_total,
.coverage_version = CBM_SEMANTIC_INDEX_VERSION,
.hash_records_complete = true,
.unresolved_calls_complete =
pipeline_unresolved_capture_complete(p, coverage_rows_available),
},
.surface_rows = p->surface_rows,
.surface_row_count = p->surface_row_count,
+72 -10
View File
@@ -35,6 +35,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 };
#include "foundation/limits.h" /* cbm_max_file_bytes */
#include "foundation/platform.h"
#include "foundation/sha256.h"
#include "result_spill.h"
#include <ctype.h>
#include <errno.h>
@@ -3244,6 +3245,31 @@ done:
return 1;
}
static bool incremental_metadata_current(int meta_rc, const cbm_coverage_meta_t *meta,
const char *mode_name) {
return meta_rc == CBM_STORE_OK && meta->coverage_version == CBM_SEMANTIC_INDEX_VERSION &&
meta->unresolved_calls_complete && meta->hash_records_complete && meta->index_mode &&
strcmp(meta->index_mode, mode_name) == 0;
}
static bool incremental_retained_capture_complete(cbm_store_t *store, const char *project) {
cbm_coverage_meta_t meta = {0};
bool complete = cbm_store_coverage_meta_get(store, project, &meta) == CBM_STORE_OK &&
meta.coverage_version >= CBM_UNRESOLVED_CALL_COVERAGE_VERSION &&
meta.unresolved_calls_complete;
cbm_store_coverage_meta_clear(&meta);
return complete;
}
static bool incremental_coverage_rows_available(int count, const cbm_coverage_row_t *rows) {
return count == 0 || rows != NULL;
}
static bool incremental_capture_complete(bool rows_available, bool run_complete,
bool retained_complete) {
return rows_available && run_complete && retained_complete;
}
/* ── Delta-repair orchestration (closure route) ──────────────────
*
* The closure route's executor: clone the live generation, repair the
@@ -3260,8 +3286,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
closure_plan_t *plan, cbm_file_info_t *changed_files, int ci,
char **deleted, int deleted_count, cbm_file_hash_t *mode_skipped,
int mode_skipped_count, cbm_coverage_row_t *old_cov, int old_cov_count,
cbm_store_t *route_store, const cbm_file_info_t *files, int file_count,
struct timespec t0) {
bool retained_unresolved_complete, cbm_store_t *route_store,
const cbm_file_info_t *files, int file_count, struct timespec t0) {
struct timespec t;
cbm_store_close(route_store);
@@ -3637,7 +3663,13 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
int run_ignored_count = 0;
int run_ignored_total = 0;
cbm_pipeline_get_ignored(p, &run_ignored, &run_ignored_count, &run_ignored_total);
int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count;
cbm_coverage_row_t *run_unresolved = NULL;
int run_unresolved_count = 0;
bool unresolved_complete = false;
cbm_pipeline_get_unresolved_calls(p, &run_unresolved, &run_unresolved_count,
&unresolved_complete);
int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count +
run_unresolved_count;
bool coverage_rows_available = cov_cap == 0;
if (cov_cap > 0) {
cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov));
@@ -3657,6 +3689,9 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
cov[cov_n].detail = run_errs[i].reason;
cov_n++;
}
for (int i = 0; i < run_unresolved_count; i++) {
cov[cov_n++] = run_unresolved[i];
}
for (int i = 0; i < run_excluded_count; i++) {
cov[cov_n].rel_path = run_excluded[i];
cov[cov_n].kind = "not_indexed_dir";
@@ -3719,6 +3754,17 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
cbm_store_count_edges(staging, project));
cbm_log_info("delta.committed_counts", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
/* The staging file started as a byte-for-byte clone of the previous
* generation (cbm_delta_stage_clone above), so its `projects` row still
* carries whichever root_path/indexed_at the LAST full rebuild wrote.
* Every other write in this function patches nodes/edges/hashes/surfaces
* for the CURRENT run's repo_path, so the project row must be refreshed
* to match, or a later root_path-keyed read (get_code_snippet and
* friends) resolves files against a directory this run never touched. */
if (cbm_store_upsert_project(staging, project, cbm_pipeline_repo_path(p)) != CBM_STORE_OK) {
goto out;
}
{
int index_mode = cbm_pipeline_get_mode(p);
cbm_pipeline_generation_t generation = {
@@ -3742,6 +3788,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
.ignored_files_total = run_ignored_total,
.coverage_version = CBM_SEMANTIC_INDEX_VERSION,
.hash_records_complete = true,
.unresolved_calls_complete = incremental_capture_complete(
coverage_rows_available, unresolved_complete, retained_unresolved_complete),
},
.surface_rows = NULL,
.surface_row_count = 0,
@@ -3851,9 +3899,7 @@ static bool incr_generation_exact(cbm_pipeline_t *p, cbm_store_t *store, const c
cbm_coverage_meta_t meta = {0};
int meta_rc = cbm_store_coverage_meta_get(store, project, &meta);
const char *mode_name = incr_mode_name(cbm_pipeline_get_mode(p));
bool metadata_current =
meta_rc == CBM_STORE_OK && meta.coverage_version == CBM_SEMANTIC_INDEX_VERSION &&
meta.hash_records_complete && meta.index_mode && strcmp(meta.index_mode, mode_name) == 0;
bool metadata_current = incremental_metadata_current(meta_rc, &meta, mode_name);
/* A generation whose doc-link layer is missing or failed is not
* current even with identical inputs: index_status tells the user to
* re-run, and the re-run has to rebuild. */
@@ -4043,6 +4089,11 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
cbm_store_free_file_hashes(stored, stored_count);
/* A subset repair cannot recover unresolved evidence omitted by an older
* attempt. Normal routing rebuilds such baselines; the legacy test route
* also preserves their incomplete signal when it retains unchanged files. */
bool retained_unresolved_complete = incremental_retained_capture_complete(store, project);
/* Coverage rows (#963): the dump below rebuilds the DB file, wiping the
* separate index_coverage table — capture the previous rows now (store
* still open) so entries for files NOT re-extracted this run survive. */
@@ -4086,8 +4137,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
* live database; its every failure falls back to a full rebuild. */
return run_closure_delta(p, db_path, project, baseline_manifest, baseline_count,
&closure_plan, changed_files, ci, deleted, deleted_count,
mode_skipped, mode_skipped_count, old_cov, old_cov_count, store,
files, file_count, t0);
mode_skipped, mode_skipped_count, old_cov, old_cov_count,
retained_unresolved_complete, store, files, file_count, t0);
}
struct timespec t;
@@ -4359,11 +4410,17 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
cbm_pipeline_get_ignored(p, &run_ignored, &run_ignored_count, &run_ignored_total);
cbm_coverage_row_t *cov = NULL;
int cov_n = 0;
int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count;
cbm_coverage_row_t *run_unresolved = NULL;
int run_unresolved_count = 0;
bool unresolved_complete = false;
cbm_pipeline_get_unresolved_calls(p, &run_unresolved, &run_unresolved_count,
&unresolved_complete);
int cov_cap = old_cov_count + run_err_count + run_excluded_count + run_ignored_count +
run_unresolved_count;
if (cov_cap > 0) {
cov = (cbm_coverage_row_t *)malloc((size_t)cov_cap * sizeof(*cov));
}
bool coverage_rows_available = cov_cap == 0 || cov != NULL;
bool coverage_rows_available = incremental_coverage_rows_available(cov_cap, cov);
if (cov) {
CBMHashTable *changed_set = cbm_ht_create(ci > 0 ? (size_t)ci * PAIR_LEN : CBM_SZ_64);
for (int i = 0; i < ci; i++) {
@@ -4383,6 +4440,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
cov[cov_n].detail = run_errs[i].reason;
cov_n++;
}
for (int i = 0; i < run_unresolved_count; i++) {
cov[cov_n++] = run_unresolved[i];
}
for (int i = 0; i < run_excluded_count; i++) {
cov[cov_n].rel_path = run_excluded[i];
cov[cov_n].kind = "not_indexed_dir";
@@ -4450,6 +4510,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
.ignored_files_total = run_ignored_total,
.coverage_version = CBM_SEMANTIC_INDEX_VERSION,
.hash_records_complete = true,
.unresolved_calls_complete = incremental_capture_complete(
coverage_rows_available, unresolved_complete, retained_unresolved_complete),
};
/* Publish surfaces: the surviving previous rows plus this run's fresh
* ones (closure route). The legacy test route publishes none — its
+23 -2
View File
@@ -1655,8 +1655,16 @@ int cbm_pipeline_build_fresh_semantic_manifest(cbm_pipeline_t *p, const char *pr
* a kube / k8s task DEPLOYS {via:ansible} the template it applies
* (pass_ansible.c). An external Chart is keyed by its publisher URL.
* An index built before holds none of them, so it rebuilds once on
* upgrade. */
enum { CBM_SEMANTIC_INDEX_VERSION = 9 };
* upgrade.
* 10: combine the platform variants, stem disambiguation and infrastructure
* layers above with unresolved-call evidence capture and its candidate
* index. Both upstream version 9 and this branch's earlier version 9
* lack part of these semantics, so unchanged files rebuild once.
* 11: Python local defs and classes bind through their enclosing lexical
* scope, including app-factory route handlers. Published version 10
* predates this resolution, so unchanged files rebuild once on upgrade.
* The unresolved-call coverage threshold remains independent. */
enum { CBM_SEMANTIC_INDEX_VERSION = 11 };
typedef struct {
cbm_gbuf_t *gbuf;
@@ -1814,6 +1822,19 @@ void cbm_pipeline_discard_stage(const char *stage_path);
* Takes ownership; dump_and_persist_hashes writes them into the staging
* store and cbm_pipeline_free releases them. Passing NULL/0 clears. */
void cbm_pipeline_set_lsp_surfaces(cbm_pipeline_t *p, cbm_lsp_surface_row_t *rows, int count);
/* One entry per call, owned by the current file's resolve pass. Candidate
* strings are borrowed from the graph and copied by coverage capture. */
typedef struct {
const char *candidate_qn;
bool calls_emitted;
} CBMCallEvidence;
void cbm_pipeline_record_unresolved_calls(cbm_pipeline_t *p, const char *rel_path,
const CBMFileResult *result,
const CBMCallEvidence *evidence);
void cbm_pipeline_mark_unresolved_capture_failed(cbm_pipeline_t *p);
void cbm_pipeline_get_unresolved_calls(cbm_pipeline_t *p, cbm_coverage_row_t **rows, int *count,
bool *complete);
/* The run's doc_link_unresolved rows and failure flag (doc_links.h), taken
* over by the pipeline (set replaces and frees earlier rows; NULL p frees).
* A full run publishes them from dump_and_persist_hashes; an incremental
+229 -75
View File
@@ -423,6 +423,20 @@ static int init_schema(cbm_store_t *s) {
" detail TEXT DEFAULT '',"
" PRIMARY KEY (project, rel_path, kind)"
");"
"CREATE INDEX IF NOT EXISTS idx_index_coverage_project_kind "
"ON index_coverage(project, kind);"
/* Derived candidate lookup: inbound traces read only this index, not
* every file's diagnostic JSON. An empty candidate marks truncated or
* malformed evidence, whose omitted candidates cannot be ruled out. */
"CREATE TABLE IF NOT EXISTS index_unresolved_candidates ("
" project TEXT NOT NULL,"
" candidate TEXT NOT NULL,"
" rel_path TEXT NOT NULL,"
" kind TEXT NOT NULL DEFAULT 'unresolved_calls' CHECK(kind='unresolved_calls'),"
" PRIMARY KEY(project, candidate, rel_path),"
" FOREIGN KEY(project, rel_path, kind) "
" REFERENCES index_coverage(project, rel_path, kind) ON DELETE CASCADE"
");"
/* Observed runtime calls (ingest_traces). Deliberately SEPARATE from
* the graph tables (like index_coverage) and keyed by QUALIFIED NAME,
* not node id: node ids are reassigned on every reindex, so id-keyed
@@ -463,7 +477,8 @@ static int init_schema(cbm_store_t *s) {
" ignored_files_stored INTEGER NOT NULL DEFAULT 0,"
" ignored_files_total INTEGER NOT NULL DEFAULT 0,"
" coverage_version INTEGER NOT NULL DEFAULT 1,"
" hash_records_complete INTEGER NOT NULL DEFAULT 0"
" hash_records_complete INTEGER NOT NULL DEFAULT 0,"
" unresolved_calls_complete INTEGER NOT NULL DEFAULT 0"
");";
int rc = exec_sql(s, ddl);
@@ -471,6 +486,33 @@ static int init_schema(cbm_store_t *s) {
return rc;
}
/* Existing writable stores retain their ordinary coverage metadata, but
* unresolved capture is untrusted until a full generation records it. */
sqlite3_stmt *coverage_columns = NULL;
if (sqlite3_prepare_v2(s->db, "PRAGMA table_info(index_coverage_meta);", CBM_NOT_FOUND,
&coverage_columns, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta schema prepare");
return CBM_STORE_ERR;
}
bool have_unresolved_complete = false;
int column_rc;
while ((column_rc = sqlite3_step(coverage_columns)) == SQLITE_ROW) {
const char *name = (const char *)sqlite3_column_text(coverage_columns, SKIP_ONE);
if (name && strcmp(name, "unresolved_calls_complete") == 0) {
have_unresolved_complete = true;
}
}
sqlite3_finalize(coverage_columns);
if (column_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta schema scan");
return CBM_STORE_ERR;
}
if (!have_unresolved_complete &&
exec_sql(s, "ALTER TABLE index_coverage_meta ADD COLUMN "
"unresolved_calls_complete INTEGER NOT NULL DEFAULT 0;") != CBM_STORE_OK) {
return CBM_STORE_ERR;
}
/* Schema-compat probe (#768): DBs created before the local_name_gen
* discriminator still enforce UNIQUE(source_id,target_id,type) and lack
* the column — the widened upsert in cbm_store_insert_edge can neither
@@ -4474,6 +4516,18 @@ static void cov_json_escape(char *dst, size_t dstsz, const char *src) {
* {kind, detail}. Queryable via query_graph(graph="missed") with zero
* cypher-engine changes; the real project's graph gains no rows. Derived
* data — rebuilt from the authoritative (post-prune) table contents. */
/* Coverage kinds that never materialize in the miss graph. not_indexed_*
* rows are by-design exclusions, not failures. unresolved_calls rows (#2265)
* describe files that WERE indexed; their call evidence is reported by
* check_index_coverage and trace_path, and as a File row it would also
* replace a parse_partial row for the same path (rows arrive ordered by
* rel_path, kind, and the File upsert keeps the last one). */
static bool cov_kind_outside_miss_graph(const char *kind) {
static const char by_design_prefix[] = "not_indexed";
return strncmp(kind, by_design_prefix, sizeof(by_design_prefix) - 1) == 0 ||
strcmp(kind, "unresolved_calls") == 0;
}
/* Fingerprint of the FAILURE rows the shadow graph derives from. When the
* set is unchanged since the last rebuild, the wipe-and-rebuild (tens of
* thousands of node/edge upserts probing the full-size nodes index -- a
@@ -4485,7 +4539,7 @@ static void cov_failure_fingerprint(const cbm_coverage_row_t *rows, int count,
cbm_sha256_ctx sha;
cbm_sha256_init(&sha);
for (int i = 0; i < count; i++) {
if (!rows[i].kind || strncmp(rows[i].kind, "not_indexed", 11) == 0) {
if (!rows[i].kind || cov_kind_outside_miss_graph(rows[i].kind)) {
continue;
}
const char *rel = rows[i].rel_path ? rows[i].rel_path : "";
@@ -4584,11 +4638,11 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) {
return CBM_STORE_OK;
}
/* Only FAILURE rows materialize in the miss graph; a project whose only
* coverage rows are by-design not_indexed_* entries gets NO graph (not
* even a bare root node). */
* coverage rows are by-design not_indexed_* entries or unresolved_calls
* evidence gets NO graph (not even a bare root node). */
int failure_count = 0;
for (int i = 0; i < count; i++) {
if (!rows[i].kind || strncmp(rows[i].kind, "not_indexed", 11) != 0) {
if (!rows[i].kind || !cov_kind_outside_miss_graph(rows[i].kind)) {
failure_count++;
}
}
@@ -4629,8 +4683,10 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) {
}
/* The missed graph shows FAILURES ("we did not manage") only —
* by-design not_indexed_* rows stay out of it, so the UI's
* report-an-edge-case callout never fires for gitignored paths. */
if (rows[i].kind && strncmp(rows[i].kind, "not_indexed", 11) == 0) {
* report-an-edge-case callout never fires for gitignored paths, and
* unresolved_calls rows stay out so a parse_partial row for the same
* file keeps its File node. */
if (rows[i].kind && cov_kind_outside_miss_graph(rows[i].kind)) {
continue;
}
char pathbuf[CBM_SZ_1K];
@@ -4735,6 +4791,114 @@ static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) {
return CBM_STORE_OK;
}
/* Materialize once at publication, after replacement and deleted-file pruning.
* json_each sees a safe marker array for malformed/legacy non-array payloads.
* The side table cascades with its authoritative coverage rows. */
static int coverage_index_unresolved_candidates(cbm_store_t *s, const char *project) {
static const char sql[] =
"INSERT OR IGNORE INTO index_unresolved_candidates(project, candidate, rel_path) "
"SELECT c.project, CASE WHEN j.type != 'object' OR "
"json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.truncated') = 1 "
"THEN '' "
"ELSE json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.candidate') "
"END, c.rel_path "
"FROM index_coverage c, json_each(CASE "
"WHEN json_valid(c.detail) THEN CASE WHEN json_type(c.detail) = 'array' "
"THEN c.detail ELSE '[{\"truncated\":true}]' END "
"ELSE '[{\"truncated\":true}]' END) j "
"WHERE c.project = ?1 AND c.kind = 'unresolved_calls' AND "
"(j.type != 'object' OR json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' "
"END, '$.truncated') = 1 OR "
"(json_type(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.candidate') = "
"'text' AND "
"length(json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, "
"'$.candidate')) > 0));";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "unresolved candidate index prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "unresolved candidate index");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* The caller owns the coverage transaction and rolls it back on failure. */
static int coverage_write_metadata(cbm_store_t *s, const char *project,
const cbm_coverage_meta_t *meta) {
enum { COVERAGE_META_INITIAL_VERSION = 1 };
if (!meta) {
sqlite3_stmt *del_meta = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage_meta WHERE project = ?1;",
CBM_NOT_FOUND, &del_meta, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta delete prepare");
return CBM_STORE_ERR;
}
bind_text(del_meta, SKIP_ONE, project);
int meta_rc = sqlite3_step(del_meta);
sqlite3_finalize(del_meta);
if (meta_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta delete");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
char recorded_at[CBM_SZ_64];
if (meta->recorded_at && meta->recorded_at[0]) {
(void)snprintf(recorded_at, sizeof(recorded_at), "%s", meta->recorded_at);
} else {
iso_now(recorded_at, sizeof(recorded_at));
}
const char *generation =
meta->generation && meta->generation[0] ? meta->generation : recorded_at;
const char *index_mode = meta->index_mode && meta->index_mode[0] ? meta->index_mode : "unknown";
const char *recording_status = meta->recording_status && meta->recording_status[0]
? meta->recording_status
: "unavailable";
int ignored_stored = meta->ignored_files_stored > 0 ? meta->ignored_files_stored : 0;
int ignored_total = meta->ignored_files_total > 0 ? meta->ignored_files_total : 0;
int coverage_version =
meta->coverage_version > 0 ? meta->coverage_version : COVERAGE_META_INITIAL_VERSION;
sqlite3_stmt *up_meta = NULL;
if (sqlite3_prepare_v2(s->db,
"INSERT INTO index_coverage_meta "
"(project, generation, index_mode, recorded_at, recording_status, "
" ignored_files_stored, ignored_files_total, coverage_version, "
" hash_records_complete, unresolved_calls_complete) "
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) "
"ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, "
"recorded_at=?4, recording_status=?5, ignored_files_stored=?6, "
"ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9, "
"unresolved_calls_complete=?10;",
CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta upsert prepare");
return CBM_STORE_ERR;
}
bind_text(up_meta, SKIP_ONE, project);
bind_text(up_meta, ST_COL_2, generation);
bind_text(up_meta, ST_COL_3, index_mode);
bind_text(up_meta, CBM_SZ_4, recorded_at);
bind_text(up_meta, CBM_SZ_5, recording_status);
sqlite3_bind_int(up_meta, ST_COL_6, ignored_stored);
sqlite3_bind_int(up_meta, ST_COL_7, ignored_total);
sqlite3_bind_int(up_meta, ST_COL_8, coverage_version);
sqlite3_bind_int(up_meta, ST_COL_9, meta->hash_records_complete);
sqlite3_bind_int(up_meta, ST_COL_10, meta->unresolved_calls_complete);
int meta_rc = sqlite3_step(up_meta);
sqlite3_finalize(up_meta);
if (meta_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta upsert");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project,
const cbm_coverage_row_t *rows, int count,
const cbm_coverage_meta_t *meta) {
@@ -4832,72 +4996,14 @@ int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project,
(cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000;
cov_t0 = cov_t1;
if (meta) {
char recorded_at[CBM_SZ_64];
if (meta->recorded_at && meta->recorded_at[0]) {
snprintf(recorded_at, sizeof(recorded_at), "%s", meta->recorded_at);
} else {
iso_now(recorded_at, sizeof(recorded_at));
}
const char *generation =
meta->generation && meta->generation[0] ? meta->generation : recorded_at;
const char *index_mode =
meta->index_mode && meta->index_mode[0] ? meta->index_mode : "unknown";
const char *recording_status = meta->recording_status && meta->recording_status[0]
? meta->recording_status
: "unavailable";
int ignored_stored = meta->ignored_files_stored > 0 ? meta->ignored_files_stored : 0;
int ignored_total = meta->ignored_files_total > 0 ? meta->ignored_files_total : 0;
int coverage_version = meta->coverage_version > 0 ? meta->coverage_version : 1;
if (coverage_index_unresolved_candidates(s, project) != CBM_STORE_OK) {
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
sqlite3_stmt *up_meta = NULL;
if (sqlite3_prepare_v2(
s->db,
"INSERT INTO index_coverage_meta "
"(project, generation, index_mode, recorded_at, recording_status, "
" ignored_files_stored, ignored_files_total, coverage_version, "
" hash_records_complete) "
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) "
"ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, "
"recorded_at=?4, recording_status=?5, ignored_files_stored=?6, "
"ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9;",
CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta upsert prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(up_meta, SKIP_ONE, project);
bind_text(up_meta, ST_COL_2, generation);
bind_text(up_meta, ST_COL_3, index_mode);
bind_text(up_meta, CBM_SZ_4, recorded_at);
bind_text(up_meta, CBM_SZ_5, recording_status);
sqlite3_bind_int(up_meta, 6, ignored_stored);
sqlite3_bind_int(up_meta, 7, ignored_total);
sqlite3_bind_int(up_meta, 8, coverage_version);
sqlite3_bind_int(up_meta, 9, meta->hash_records_complete ? 1 : 0);
int meta_rc = sqlite3_step(up_meta);
sqlite3_finalize(up_meta);
if (meta_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta upsert");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
} else {
sqlite3_stmt *del_meta = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage_meta WHERE project = ?1;",
CBM_NOT_FOUND, &del_meta, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta delete prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(del_meta, SKIP_ONE, project);
int meta_rc = sqlite3_step(del_meta);
sqlite3_finalize(del_meta);
if (meta_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta delete");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
if (coverage_write_metadata(s, project, meta) != CBM_STORE_OK) {
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
/* Rebuild the derived miss-graph view from the now-authoritative table
@@ -5019,6 +5125,43 @@ int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char
return coverage_query_rows(s, project, scope, sql, out, count);
}
int cbm_store_coverage_get_unresolved_path(cbm_store_t *s, const char *project,
const char *rel_path, cbm_coverage_row_t **out,
int *count) {
static const char sql[] = "SELECT rel_path, kind, detail FROM index_coverage "
"WHERE project = ?1 AND rel_path = ?2 "
"AND kind = 'unresolved_calls';";
return coverage_query_rows(s, project, rel_path, sql, out, count);
}
int cbm_store_coverage_has_unresolved_candidate(cbm_store_t *s, const char *project,
const char *candidate, bool *out) {
if (!out) {
return CBM_STORE_ERR;
}
*out = false;
if (!s || !s->db || !project || !candidate) {
return CBM_STORE_ERR;
}
static const char sql[] = "SELECT 1 FROM index_unresolved_candidates "
"WHERE project = ?1 AND candidate IN ('', ?2) LIMIT 1;";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "unresolved candidate lookup prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, candidate);
int rc = sqlite3_step(stmt);
*out = rc == SQLITE_ROW;
sqlite3_finalize(stmt);
if (rc != SQLITE_ROW && rc != SQLITE_DONE) {
store_set_error_sqlite(s, "unresolved candidate lookup");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) {
if (!meta) {
return;
@@ -5043,10 +5186,20 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag
if (sqlite3_prepare_v2(s->db,
"SELECT project, generation, index_mode, recorded_at, recording_status, "
"ignored_files_stored, ignored_files_total, coverage_version, "
"hash_records_complete FROM index_coverage_meta WHERE project = ?1;",
"hash_records_complete, unresolved_calls_complete "
"FROM index_coverage_meta WHERE project = ?1;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta get prepare");
return CBM_STORE_ERR;
/* Query opens never migrate a legacy database. Preserve its ordinary
* report while the absent new signal remains explicitly incomplete. */
if (sqlite3_prepare_v2(
s->db,
"SELECT project, generation, index_mode, recorded_at, recording_status, "
"ignored_files_stored, ignored_files_total, coverage_version, "
"hash_records_complete, 0 FROM index_coverage_meta WHERE project = ?1;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta get prepare");
return CBM_STORE_ERR;
}
}
bind_text(stmt, SKIP_ONE, project);
int rc = sqlite3_step(stmt);
@@ -5060,6 +5213,7 @@ int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverag
out->ignored_files_total = sqlite3_column_int(stmt, 6);
out->coverage_version = sqlite3_column_int(stmt, 7);
out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0;
out->unresolved_calls_complete = sqlite3_column_int(stmt, ST_COL_9) != 0;
sqlite3_finalize(stmt);
if (!out->project || !out->generation || !out->index_mode || !out->recorded_at ||
!out->recording_status) {
+21 -3
View File
@@ -769,7 +769,9 @@ int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project);
/* One best-effort coverage row: a file the indexer could not fully cover.
* kind "parse_partial" = indexed but the parse tree had ERROR/MISSING regions
* (detail = 1-based line ranges "12-40,88-90"); skip kinds "read"/"extract"/
* "oversized" = not indexed at all (detail = reason). Stored in the separate
* "oversized" = not indexed at all (detail = reason). "unresolved_calls" is
* indexed source whose invocation targets were not resolved (detail = JSON
* array of caller, leaf, source byte span, and reason). Stored in the separate
* index_coverage table — coverage is metadata ABOUT the graph, never mixed
* into the graph itself. */
typedef struct {
@@ -778,9 +780,15 @@ typedef struct {
const char *detail;
} cbm_coverage_row_t;
/* Older generations lack precise caller attribution and resolver candidates,
* so their exact trace totals cannot be trusted until the project is reindexed. */
enum { CBM_UNRESOLVED_CALL_COVERAGE_VERSION = 5 };
/* Metadata describing how completely one index run recorded the best-effort
* coverage signal. `recording_status` is "complete", "truncated", or
* "unavailable"; it is deliberately separate from hash_records_complete.
* unresolved_calls_complete describes only unresolved invocation capture;
* false keeps CALLS totals conservative without voiding other coverage.
* Strings returned by cbm_store_coverage_meta_get are heap-owned. */
typedef struct {
const char *project;
@@ -792,6 +800,7 @@ typedef struct {
int ignored_files_total;
int coverage_version;
bool hash_records_complete;
bool unresolved_calls_complete;
} cbm_coverage_meta_t;
/* Replace the project's coverage rows in one transaction, then prune rows for
@@ -820,6 +829,14 @@ int cbm_store_coverage_get_path(cbm_store_t *s, const char *project, const char
* ancestor that covers the scope. Prefix matching is segment-boundary safe. */
int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char *scope,
cbm_coverage_row_t **out, int *count);
/* Fetch bounded unresolved invocation diagnostics for one exact caller file. */
int cbm_store_coverage_get_unresolved_path(cbm_store_t *s, const char *project,
const char *rel_path, cbm_coverage_row_t **out,
int *count);
/* Indexed candidate existence; truncated/malformed evidence also returns true
* because omitted candidates cannot prove an exact inbound total. */
int cbm_store_coverage_has_unresolved_candidate(cbm_store_t *s, const char *project,
const char *candidate, bool *out);
/* Fetch/free the metadata paired with the current coverage row set. */
int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out);
@@ -843,10 +860,11 @@ int cbm_store_observed_lookup(cbm_store_t *s, const char *project, const char *c
void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta);
/* Name of the derived miss-graph shadow project ("<project>::missed").
* cbm_store_coverage_replace materializes the coverage rows as a file-
* cbm_store_coverage_replace materializes the coverage FAILURE rows as a file-
* structure graph (Project → Folder → File{kind, detail}) under this project
* name — queryable via the normal cypher path without touching the real
* project's graph. */
* project's graph. By-design not_indexed_* rows and unresolved_calls evidence
* stay out of it. */
void cbm_store_coverage_shadow_project(char *dst, size_t dstsz, const char *project);
void cbm_store_free_coverage(cbm_coverage_row_t *rows, int count);
+34 -4
View File
@@ -22,6 +22,8 @@
#include <stdio.h>
#include <string.h>
enum { SC_SQL_TEXT_LENGTH = -1 };
typedef struct {
const char *name;
const char *sql; /* ?1 = project; every column is hashed, rows in this order */
@@ -44,11 +46,15 @@ static const sc_query_t sc_queries[] = {
"main.lsp_surface WHERE project=?1 ORDER BY 1,2,3,4,5;"},
{"index_coverage", "SELECT rel_path,kind,detail FROM main.index_coverage WHERE project=?1 "
"ORDER BY 1,2,3;"},
{"index_coverage_meta", "SELECT index_mode,recording_status,ignored_files_stored,"
"ignored_files_total,coverage_version,hash_records_complete FROM "
"main.index_coverage_meta WHERE project=?1;"},
{"index_coverage_meta",
"SELECT index_mode,recording_status,ignored_files_stored,"
"ignored_files_total,coverage_version,hash_records_complete,unresolved_calls_complete FROM "
"main.index_coverage_meta WHERE project=?1;"},
{"project_summaries", "SELECT summary,source_hash FROM main.project_summaries WHERE "
"project=?1;"},
{"index_unresolved_candidates", "SELECT candidate,rel_path,kind FROM "
"main.index_unresolved_candidates WHERE project=?1 "
"ORDER BY 1,2,3;"},
};
/* What test selection reads: node identity and every edge by its endpoints
@@ -84,7 +90,31 @@ static void sc_frame(cbm_sha256_ctx *ctx, unsigned char tag, const void *bytes,
static bool sc_table(sqlite3 *db, const sc_query_t *q, const char *project, cbm_sha256_ctx *ctx) {
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db, q->sql, -1, &st, NULL) != SQLITE_OK) {
return false;
sqlite3_finalize(st);
st = NULL;
if (strcmp(q->name, "index_unresolved_candidates") == 0) {
/* Omit the optional frame only for proven legacy absence. Errors
* reading a present candidate index must invalidate the digest. */
sqlite3_stmt *schema = NULL;
bool absent =
sqlite3_prepare_v2(
db,
"SELECT 1 FROM main.sqlite_schema WHERE name='index_unresolved_candidates' "
"COLLATE NOCASE LIMIT 1;",
SC_SQL_TEXT_LENGTH, &schema, NULL) == SQLITE_OK &&
sqlite3_step(schema) == SQLITE_DONE;
return sqlite3_finalize(schema) == SQLITE_OK && absent;
}
/* Read-only legacy databases retain their existing content digest. */
if (strcmp(q->name, "index_coverage_meta") != 0 ||
sqlite3_prepare_v2(db,
"SELECT index_mode,recording_status,ignored_files_stored,"
"ignored_files_total,coverage_version,hash_records_complete FROM "
"main.index_coverage_meta WHERE project=?1;",
SC_SQL_TEXT_LENGTH, &st, NULL) != SQLITE_OK) {
sqlite3_finalize(st);
return false;
}
}
bool ok = sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK;
sc_frame(ctx, 'T', q->name, strlen(q->name));
+102 -39
View File
@@ -16,9 +16,14 @@ enum {
GD_ALLOW_INTEGER = 1 << GD_INTEGER,
GD_ALLOW_TEXT = 1 << GD_TEXT,
GD_ALLOW_BLOB = 1 << GD_BLOB,
GD_TABLE_COUNT = 9,
GD_MAX_COLUMNS = 9,
GD_TABLE_COUNT = 10,
GD_MAX_COLUMNS = 10,
GD_COVERAGE_META = 7,
GD_STORE_META = 8,
GD_UNRESOLVED_CANDIDATES = 9,
GD_SCHEMA_DECLARED_TYPE = 2,
GD_SCHEMA_PRIMARY_KEY = 5,
GD_SCHEMA_HIDDEN = 6,
GD_CHUNK_BYTES = 65536
};
@@ -87,9 +92,15 @@ static const gd_column_t gd_index_coverage_meta[] = {{"project", GD_T, 0, 1},
{"ignored_files_stored", GD_I, 0, 0},
{"ignored_files_total", GD_I, 0, 0},
{"coverage_version", GD_I, 0, 0},
{"hash_records_complete", GD_I, 0, 0}};
{"hash_records_complete", GD_I, 0, 0},
{"unresolved_calls_complete", GD_I, 0, 0}};
static const gd_column_t gd_store_meta[] = {{"k", GD_T, 0, 1}, {"v", GD_T, 0, 0}};
static const gd_column_t gd_unresolved_candidates[] = {{"project", GD_T, 0, 1},
{"candidate", GD_T, 0, 2},
{"rel_path", GD_T, 0, 3},
{"kind", GD_T, 0, 0}};
/* These descriptors and SQL strings are immutable; all other state is local. */
static const gd_table_t gd_tables[GD_TABLE_COUNT] = {
{"projects", gd_projects, GD_COLUMNS(gd_projects), "PRAGMA main.table_xinfo('projects');",
@@ -116,14 +127,28 @@ static const gd_table_t gd_tables[GD_TABLE_COUNT] = {
"PRAGMA main.table_xinfo('index_coverage');",
"SELECT project,rel_path,kind,detail FROM main.index_coverage "
"ORDER BY CAST(project AS BLOB) ASC,CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"},
{"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta),
{"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta) - 1,
"PRAGMA main.table_xinfo('index_coverage_meta');",
"SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored,"
"ignored_files_total,coverage_version,hash_records_complete FROM main.index_coverage_meta "
"ORDER BY CAST(project AS BLOB) ASC;"},
{"store_meta", gd_store_meta, GD_COLUMNS(gd_store_meta),
"PRAGMA main.table_xinfo('store_meta');",
"SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"}};
"SELECT k,v FROM main.store_meta ORDER BY CAST(k AS BLOB) ASC;"},
{"index_unresolved_candidates", gd_unresolved_candidates, GD_COLUMNS(gd_unresolved_candidates),
"PRAGMA main.table_xinfo('index_unresolved_candidates');",
"SELECT project,candidate,rel_path,kind FROM main.index_unresolved_candidates "
"ORDER BY CAST(project AS BLOB) ASC,CAST(candidate AS BLOB) ASC,"
"CAST(rel_path AS BLOB) ASC,CAST(kind AS BLOB) ASC;"}};
/* Preserve the legacy canonical stream; newer metadata binds the independent
* capture completeness column as well. Both schemas are checked exactly. */
static const gd_table_t gd_coverage_meta_current = {
"index_coverage_meta", gd_index_coverage_meta, GD_COLUMNS(gd_index_coverage_meta),
"PRAGMA main.table_xinfo('index_coverage_meta');",
"SELECT project,generation,index_mode,recorded_at,recording_status,ignored_files_stored,"
"ignored_files_total,coverage_version,hash_records_complete,unresolved_calls_complete "
"FROM main.index_coverage_meta ORDER BY CAST(project AS BLOB) ASC;"};
#undef GD_B
#undef GD_N
@@ -142,8 +167,14 @@ typedef struct {
uint64_t bytes;
bool have_uid;
bool have_counter;
bool have_unresolved_completeness;
} gd_context_t;
static const gd_table_t *gd_table(const gd_context_t *g, int index) {
return index == GD_COVERAGE_META && g->have_unresolved_completeness ? &gd_coverage_meta_current
: &gd_tables[index];
}
typedef struct {
const unsigned char *data;
size_t length;
@@ -408,8 +439,30 @@ static bool gd_table_kinds(gd_context_t *g, bool present[GD_TABLE_COUNT]) {
return rc == SQLITE_DONE && gd_finalize(g);
}
static bool gd_column_schema(gd_context_t *g, const gd_column_t *expected) {
if (sqlite3_column_type(g->stmt, GD_SCHEMA_PRIMARY_KEY) != SQLITE_INTEGER ||
sqlite3_column_type(g->stmt, GD_SCHEMA_HIDDEN) != SQLITE_INTEGER ||
sqlite3_column_int64(g->stmt, GD_SCHEMA_PRIMARY_KEY) != expected->primary_key ||
sqlite3_column_int64(g->stmt, GD_SCHEMA_HIDDEN) != expected->kind) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) {
gd_text_t declared_type;
if (!gd_read_text(g, GD_SCHEMA_DECLARED_TYPE, &declared_type)) {
return false;
}
if (!gd_text_ascii_eq(declared_type, "INTEGER")) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
}
return true;
}
static bool gd_columns(gd_context_t *g, const gd_table_t *table) {
bool seen[GD_MAX_COLUMNS] = {false};
bool coverage_meta = table == &gd_tables[GD_COVERAGE_META];
int allowed_columns =
coverage_meta ? gd_coverage_meta_current.column_count : table->column_count;
if (!gd_prepare(g, table->column_query)) {
return false;
}
@@ -424,37 +477,30 @@ static bool gd_columns(gd_context_t *g, const gd_table_t *table) {
return false;
}
int column = -1;
for (int i = 0; i < table->column_count; i++) {
for (int i = 0; i < allowed_columns; i++) {
if (gd_text_eq(name, table->columns[i].name)) {
column = i;
break;
}
}
if (column < 0 || seen[column] || count == table->column_count ||
sqlite3_column_type(g->stmt, 5) != SQLITE_INTEGER ||
sqlite3_column_type(g->stmt, 6) != SQLITE_INTEGER) {
if (column < 0 || seen[column] || count == allowed_columns ||
!gd_column_schema(g, &table->columns[column])) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
const gd_column_t *expected = &table->columns[column];
if (sqlite3_column_int64(g->stmt, 5) != expected->primary_key ||
sqlite3_column_int64(g->stmt, 6) != expected->kind) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
if (expected->primary_key && expected->types == GD_ALLOW_INTEGER) {
gd_text_t declared_type;
if (!gd_read_text(g, 2, &declared_type)) {
return false;
}
if (!gd_text_ascii_eq(declared_type, "INTEGER")) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
}
seen[column] = true;
count++;
}
if (rc != SQLITE_DONE || count != table->column_count) {
if (rc != SQLITE_DONE || count < table->column_count) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
for (int i = 0; i < table->column_count; i++) {
if (!seen[i]) {
return gd_fail(g, CBM_STORE_GRAPH_DIGEST_SCHEMA);
}
}
if (coverage_meta) {
g->have_unresolved_completeness = seen[table->column_count];
}
return gd_finalize(g);
}
@@ -481,8 +527,21 @@ static bool gd_project(gd_context_t *g, const unsigned char *project, size_t len
return rc == SQLITE_DONE && gd_finalize(g);
}
static bool gd_validate_schema(gd_context_t *g, bool present[GD_TABLE_COUNT],
const unsigned char *project, size_t length) {
if (!gd_encoding(g) || !gd_table_kinds(g, present)) {
return false;
}
for (int i = 0; i < GD_TABLE_COUNT; i++) {
if (present[i] && !gd_columns(g, &gd_tables[i])) {
return false;
}
}
return gd_project(g, project, length);
}
static bool gd_table_header(gd_context_t *g, int index, bool present) {
const gd_table_t *table = &gd_tables[index];
const gd_table_t *table = gd_table(g, index);
if (!gd_u8(g, 0x20) || !gd_u32(g, (uint32_t)index) || !gd_literal(g, table->name) ||
!gd_u32(g, (uint32_t)table->column_count)) {
return false;
@@ -605,7 +664,7 @@ static bool gd_column_value(gd_context_t *g, int index, const gd_column_t *colum
static bool gd_table_rows(gd_context_t *g, int index, bool present) {
uint64_t rows = 0;
const gd_table_t *table = &gd_tables[index];
const gd_table_t *table = gd_table(g, index);
if (present) {
if (!gd_prepare(g, table->row_query)) {
return false;
@@ -645,6 +704,20 @@ static bool gd_table_rows(gd_context_t *g, int index, bool present) {
return gd_u8(g, 0x22) && gd_u64(g, rows);
}
static bool gd_stream_tables(gd_context_t *g, const bool present[GD_TABLE_COUNT]) {
for (int i = 0; i < GD_TABLE_COUNT; i++) {
/* A store without the candidate table streams the same tables as
* version 1; only the version word differs. */
if (i == GD_UNRESOLVED_CANDIDATES && !present[i]) {
continue;
}
if (!gd_table_header(g, i, present[i]) || !gd_table_rows(g, i, present[i])) {
return false;
}
}
return true;
}
static bool gd_project_argument(gd_context_t *g, const unsigned char *project, size_t length) {
while (length) {
size_t chunk = length > GD_CHUNK_BYTES ? GD_CHUNK_BYTES : length;
@@ -694,15 +767,7 @@ cbm_store_graph_digest_status_t cbm_store_graph_digest(
return g.status;
}
bool present[GD_TABLE_COUNT] = {false};
if (!gd_encoding(&g) || !gd_table_kinds(&g, present)) {
goto done;
}
for (int i = 0; i < GD_TABLE_COUNT; i++) {
if (present[i] && !gd_columns(&g, &gd_tables[i])) {
goto done;
}
}
if (!gd_project(&g, project, project_len)) {
if (!gd_validate_schema(&g, present, project, project_len)) {
goto done;
}
cbm_sha256_init(&g.sha);
@@ -711,10 +776,8 @@ cbm_store_graph_digest_status_t cbm_store_graph_digest(
!gd_u8(&g, 0x10) || !gd_value(&g, GD_TEXT, project, project_len)) {
goto done;
}
for (int i = 0; i < GD_TABLE_COUNT; i++) {
if (!gd_table_header(&g, i, present[i]) || !gd_table_rows(&g, i, present[i])) {
goto done;
}
if (!gd_stream_tables(&g, present)) {
goto done;
}
if (!gd_u8(&g, 0x7f) || !gd_u64(&g, g.rows) || !gd_check(&g)) {
goto done;
+11 -3
View File
@@ -7,7 +7,14 @@
#include <stddef.h>
#include <stdint.h>
#define CBM_STORE_GRAPH_DIGEST_VERSION 1u
/* Canonical stream version, hashed right after the domain string. A bump
* changes every digest, so a holder of an older digest (the test-impact
* receipt's graph_sha256) sees a mismatch instead of comparing two different
* stream definitions.
* 1 The nine tables projects .. store_meta.
* 2 #2265: also index_unresolved_candidates (streamed only when present) and
* index_coverage_meta.unresolved_calls_complete (bound when present). */
#define CBM_STORE_GRAPH_DIGEST_VERSION 2u
#define CBM_STORE_GRAPH_DIGEST_BYTES 32u
typedef enum {
@@ -32,8 +39,9 @@ typedef struct {
uint64_t framed_bytes;
} cbm_store_graph_digest_t;
/* Borrow a live, exclusive D5 scope. Hash the version-1 canonical stream of
* all nine known tables, including their exact CREATE SQL and explicit project
/* Borrow a live, exclusive D5 scope. Hash the version-2 canonical stream of
* the nine legacy tables and the unresolved candidate index when present,
* including their exact CREATE SQL and explicit project
* identity. The database must use UTF-8. No new handler or transaction is used.
* Independent scopes/connections may run concurrently; no mutable state is
* shared. The callback/connection restrictions of D5 continue to apply.
@@ -288,7 +288,6 @@ TEST(invariant_discovery_fast_skip_dirs) {
{ "public" },
{ "third_party" },
{ "thirdparty" },
{ "external" },
{ "migrations" },
{ "build" }, /* build is in FAST_SKIP_DIRS, not ALWAYS */
{ "bin" },
+171
View File
@@ -764,6 +764,172 @@ TEST(activation_transaction_rejects_symlink_candidate_target_and_parent) {
PASS();
}
#if (defined(__APPLE__) || defined(__linux__)) && defined(CBM_ENABLE_TEST_SEAMS)
/* These tests reach the actual shared alias helper independently of the home
* root check. Only the link UID is substituted: resolving and inspecting the
* target still use the filesystem, without requiring privileged chown calls. */
TEST(activation_transaction_alias_rejects_untrusted_link_owner) {
char root[ACTIVATION_TEST_PATH_CAP];
char target[ACTIVATION_TEST_PATH_CAP];
char link[ACTIVATION_TEST_PATH_CAP];
char resolved[4096];
char expected[4096];
ASSERT_TRUE(activation_test_fixture(root));
ASSERT_TRUE(activation_test_path(target, root, "target"));
ASSERT_TRUE(activation_test_path(link, root, "alias"));
ASSERT_TRUE(cbm_mkdir_p(target, 0700));
ASSERT_EQ(symlink(target, link), 0);
ASSERT_NOT_NULL(realpath(target, resolved));
ASSERT_TRUE(activation_test_path(expected, resolved, "bin/cbm"));
unsigned long self = (unsigned long)geteuid();
unsigned long other = self == 0 ? 1U : 0U;
char *trusted =
cbm_activation_transaction_map_alias_for_testing(link, self, self, false, "/bin/cbm");
char *untrusted =
cbm_activation_transaction_map_alias_for_testing(link, other, self, false, "/bin/cbm");
bool trusted_maps_target = trusted && strcmp(trusted, expected) == 0;
bool untrusted_refused = untrusted == NULL;
free(trusted);
free(untrusted);
int unlink_status = unlink(link);
int cleanup_status = th_rmtree(root);
ASSERT_TRUE(trusted_maps_target);
ASSERT_TRUE(untrusted_refused);
ASSERT_EQ(unlink_status, 0);
ASSERT_EQ(cleanup_status, 0);
PASS();
}
TEST(activation_transaction_alias_allows_own_target_only_when_enabled) {
char root[ACTIVATION_TEST_PATH_CAP];
char target[ACTIVATION_TEST_PATH_CAP];
char link[ACTIVATION_TEST_PATH_CAP];
char resolved[4096];
char expected[4096];
ASSERT_TRUE(activation_test_fixture(root));
ASSERT_TRUE(activation_test_path(target, root, "target"));
ASSERT_TRUE(activation_test_path(link, root, "alias"));
ASSERT_TRUE(cbm_mkdir_p(target, 0700));
ASSERT_EQ(symlink(target, link), 0);
ASSERT_NOT_NULL(realpath(target, resolved));
ASSERT_TRUE(activation_test_path(expected, resolved, "bin/cbm"));
unsigned long self = (unsigned long)geteuid();
unsigned long trusted_owner = self == 0 ? 1U : 0U;
struct stat target_status;
ASSERT_EQ(lstat(resolved, &target_status), 0);
ASSERT_EQ((unsigned long)target_status.st_uid, self);
ASSERT_TRUE((unsigned long)target_status.st_uid != trusted_owner);
char *strict = cbm_activation_transaction_map_alias_for_testing(
link, trusted_owner, trusted_owner, false, "/bin/cbm");
char *own = cbm_activation_transaction_map_alias_for_testing(link, trusted_owner, trusted_owner,
true, "/bin/cbm");
bool strict_refused = strict == NULL;
bool own_maps_target = own && strcmp(own, expected) == 0;
free(strict);
free(own);
int unlink_status = unlink(link);
int cleanup_status = th_rmtree(root);
ASSERT_TRUE(strict_refused);
ASSERT_TRUE(own_maps_target);
ASSERT_EQ(unlink_status, 0);
ASSERT_EQ(cleanup_status, 0);
PASS();
}
#endif
/* #2306: managed Linux hosts keep /home a real directory and point the
* per-user entry elsewhere (/home/alice -> /local/home/alice). Root owns that
* entry, so it is trusted like the #2175 /home alias; the seam stands a
* fixture tree in for /home and the test account in for root. Every status is
* captured before the seam is reset so a failure cannot leak into the
* following tests. */
TEST(activation_transaction_follows_trusted_per_user_home_entry) {
#if defined(__linux__) && defined(CBM_ENABLE_TEST_SEAMS)
char root[ACTIVATION_TEST_PATH_CAP];
char home[ACTIVATION_TEST_PATH_CAP];
char entry[ACTIVATION_TEST_PATH_CAP];
char relocated[ACTIVATION_TEST_PATH_CAP];
char relocated_bin[ACTIVATION_TEST_PATH_CAP];
char relocated_target[ACTIVATION_TEST_PATH_CAP];
char target[ACTIVATION_TEST_PATH_CAP];
ASSERT_TRUE(activation_test_fixture(root));
ASSERT_TRUE(activation_test_path(home, root, "home"));
ASSERT_TRUE(activation_test_path(entry, home, "alice"));
ASSERT_TRUE(activation_test_path(relocated, root, "local-alice"));
ASSERT_TRUE(activation_test_path(relocated_bin, relocated, "bin"));
ASSERT_TRUE(activation_test_path(relocated_target, relocated_bin, "cbm"));
ASSERT_TRUE(activation_test_path(target, entry, "bin/cbm"));
ASSERT_TRUE(cbm_mkdir_p(home, 0755));
ASSERT_EQ(chmod(home, 0755), 0);
ASSERT_TRUE(cbm_mkdir_p(relocated_bin, 0700));
ASSERT_EQ(symlink(relocated, entry), 0);
unsigned long self = (unsigned long)geteuid();
/* Without the seam the entry is an ordinary user symlink: refused. */
cbm_activation_transaction_t *transaction = NULL;
cbm_activation_transaction_status_t untrusted_status = cbm_activation_transaction_stage_bytes(
target, "candidate", strlen("candidate"), &transaction);
bool untrusted_created = transaction != NULL;
(void)cbm_activation_transaction_close(&transaction);
/* A home root belonging to another owner is refused before entry checks. */
cbm_activation_transaction_set_home_root_for_testing(home, self + 1U);
cbm_activation_transaction_status_t foreign_owner_status =
cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"),
&transaction);
bool foreign_owner_created = transaction != NULL;
(void)cbm_activation_transaction_close(&transaction);
/* A home root others can write could have its entry swapped: refused. */
cbm_activation_transaction_set_home_root_for_testing(home, self);
(void)chmod(home, 0775);
cbm_activation_transaction_status_t writable_home_status =
cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"),
&transaction);
bool writable_home_created = transaction != NULL;
(void)cbm_activation_transaction_close(&transaction);
(void)chmod(home, 0755);
/* Trusted root, trusted entry: staged and committed into the target. */
cbm_activation_transaction_status_t trusted_status = cbm_activation_transaction_stage_bytes(
target, "candidate", strlen("candidate"), &transaction);
cbm_activation_transaction_status_t commit_status =
transaction ? cbm_activation_transaction_commit(transaction, NULL, NULL)
: CBM_ACTIVATION_TRANSACTION_INVALID_STATE;
cbm_activation_transaction_status_t finalize_status =
transaction ? cbm_activation_transaction_finalize(transaction)
: CBM_ACTIVATION_TRANSACTION_INVALID_STATE;
cbm_activation_transaction_status_t close_status =
cbm_activation_transaction_close(&transaction);
cbm_activation_transaction_set_home_root_for_testing(NULL, 0);
char contents[ACTIVATION_TEST_CONTENT_CAP] = "";
bool published = activation_test_read(relocated_target, contents);
int unlink_status = unlink(entry);
int cleanup_status = th_rmtree(root);
ASSERT_EQ(untrusted_status, CBM_ACTIVATION_TRANSACTION_IO);
ASSERT_FALSE(untrusted_created);
ASSERT_EQ(foreign_owner_status, CBM_ACTIVATION_TRANSACTION_IO);
ASSERT_FALSE(foreign_owner_created);
ASSERT_EQ(writable_home_status, CBM_ACTIVATION_TRANSACTION_IO);
ASSERT_FALSE(writable_home_created);
ASSERT_EQ(trusted_status, CBM_ACTIVATION_TRANSACTION_OK);
ASSERT_EQ(commit_status, CBM_ACTIVATION_TRANSACTION_OK);
ASSERT_EQ(finalize_status, CBM_ACTIVATION_TRANSACTION_OK);
ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK);
ASSERT_TRUE(published);
ASSERT_STR_EQ(contents, "candidate");
ASSERT_EQ(unlink_status, 0);
ASSERT_EQ(cleanup_status, 0);
#endif
PASS();
}
TEST(activation_transaction_fails_closed_if_target_directory_is_replaced) {
char root[ACTIVATION_TEST_PATH_CAP];
char active[ACTIVATION_TEST_PATH_CAP];
@@ -1089,6 +1255,11 @@ SUITE(activation_transaction) {
RUN_TEST(activation_transaction_admits_group_writable_ancestor);
RUN_TEST(activation_transaction_rejects_windows_callback_allow_directory_ace);
RUN_TEST(activation_transaction_rejects_symlink_candidate_target_and_parent);
#if (defined(__APPLE__) || defined(__linux__)) && defined(CBM_ENABLE_TEST_SEAMS)
RUN_TEST(activation_transaction_alias_rejects_untrusted_link_owner);
RUN_TEST(activation_transaction_alias_allows_own_target_only_when_enabled);
#endif
RUN_TEST(activation_transaction_follows_trusted_per_user_home_entry);
RUN_TEST(activation_transaction_fails_closed_if_target_directory_is_replaced);
RUN_TEST(activation_transaction_does_not_replace_target_created_at_publish_boundary);
RUN_TEST(activation_transaction_rejects_macos_mutating_extended_acl);
+22
View File
@@ -1177,6 +1177,10 @@ TEST(client_adapter_pi_emits_parameters_and_execute) {
/* TypeBox parameters schema is embedded with Type.Object syntax. */
ASSERT_NOT_NULL(strstr(js, "Type.Object("));
ASSERT_NOT_NULL(strstr(js, "Type.String("));
/* #2787: String enums must use Type.String({ enum: [...] }) without anyOf/const */
ASSERT_NOT_NULL(strstr(js, "Type.String({ enum: ["));
ASSERT_NULL(strstr(js, "Type.Union([Type.Literal"));
ASSERT_NULL(strstr(js, "Type.Literal("));
/* A numeric registry property must remain numeric in the generated schema. */
ASSERT_NOT_NULL(strstr(js, "depth: Type.Optional(Type.Integer("));
/* TypeBox import should be present. */
@@ -1244,6 +1248,23 @@ TEST(client_adapter_pi_wraps_result_and_honors_abort) {
PASS();
}
/* #2787: Pi extension must emit string enums as Type.String({ enum: [...] })
* rather than Type.Union([Type.Literal(...)]) which TypeBox serializes as
* anyOf/const, breaking Gemini / Antigravity function declaration validation. */
TEST(client_adapter_pi_emits_string_enum_issue2787) {
char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
ASSERT_NOT_NULL(js);
ASSERT_NULL(strstr(js, "Type.Union([Type.Literal"));
ASSERT_NULL(strstr(js, "Type.Literal("));
ASSERT_NOT_NULL(strstr(js, "Type.String({ enum: ["));
ASSERT_NOT_NULL(strstr(js, "'full'"));
ASSERT_NOT_NULL(strstr(js, "'moderate'"));
ASSERT_NOT_NULL(strstr(js, "'fast'"));
ASSERT_NOT_NULL(strstr(js, "'cross-repo-intelligence'"));
free(js);
PASS();
}
/* #616 rejected `"` in its path template but not `\`, so a Windows home like
* C:\Users\urs\bin produced `\u` — an invalid unicode escape — and the whole
* auto-loaded plugin failed to parse. A broken plugin in an auto-load directory
@@ -1405,6 +1426,7 @@ SUITE(agent_clients) {
RUN_TEST(client_adapter_pi_emits_parameters_and_execute);
RUN_TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834);
RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort);
RUN_TEST(client_adapter_pi_emits_string_enum_issue2787);
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction);
+242 -6
View File
@@ -586,6 +586,10 @@ static size_t test_count_exec_hook(yyjson_val *root, const char *event_name,
return matches;
}
/* The Claude PostToolUse matcher: Read coverage plus the graph tools, whose
* calls reset the drift reminder. */
#define TEST_CLAUDE_POST_MATCHER "Read|mcp__.*codebase-memory-mcp__.*"
#ifdef _WIN32
static bool test_append_command_hook(yyjson_mut_doc *doc, yyjson_mut_val *event_entries,
const char *matcher, const char *command) {
@@ -13559,7 +13563,8 @@ TEST(cli_claude_hook_commands_use_exec_form_with_custom_config_dir) {
static const char *const matchers[] = {"startup", "resume", "clear", "compact"};
size_t owned =
test_count_exec_hook(root, "PreToolUse", "Grep|Glob|Bash", binary, "hook-augment") +
test_count_exec_hook(root, "PostToolUse", "Read", binary, "hook-augment") +
test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary,
"hook-augment") +
test_count_exec_hook(root, "SubagentStart", "*", binary, "hook-augment");
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary, "hook-augment");
@@ -14100,6 +14105,134 @@ TEST(cli_hook_augment_copilot_posttooluse_search) {
PASS();
}
/* One Claude Code Grep search for the drift tests; returns whether it was
* augmented and sets *reminded when the drift line came with it. */
static bool test_drift_search(cbm_mcp_server_t *srv, const char *session, const char *extra,
const char *pattern, bool *reminded) {
char input[640];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"%s\",%s\"tool_name\":\"Grep\","
"\"cwd\":\"/tmp/hookproj\",\"tool_input\":{\"pattern\":\"%s\"}}",
session, extra, pattern);
char *output = cbm_hook_augment_process(srv, input);
*reminded = output && strstr(output, "searches since its last codebase-memory-mcp tool call");
bool augmented = output != NULL;
free(output);
return augmented;
}
static bool test_drift_graph_call(cbm_mcp_server_t *srv, const char *session, const char *tool) {
char input[512];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"PostToolUse\",\"session_id\":\"%s\",\"tool_name\":\"%s\","
"\"cwd\":\"/tmp/hookproj\",\"tool_input\":{}}",
session, tool);
char *output = cbm_hook_augment_process(srv, input);
bool silent = output == NULL;
free(output);
return silent;
}
/* Drift reminder (#509): a Claude Code session that keeps searching without a
* graph call gets one factual line on its 3rd search, then its 6th and 12th.
* A graph tool call, by its plain or plugin name, resets the count. A search
* without an augmentation still counts and the line waits for the next one.
* Sessions are independent; Codex payloads (turn_id) and payloads without a
* session_id are never tracked. */
TEST(cli_hook_augment_drift_reminder_counts_searches) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
char *project = cbm_project_name_from_path("/tmp/hookproj");
ASSERT_NOT_NULL(project);
cbm_mcp_server_set_project(srv, project);
cbm_store_upsert_project(st, project, "/tmp/hookproj");
char qualified_name[256];
snprintf(qualified_name, sizeof(qualified_name), "%s.mod.someIndexedSymbol", project);
cbm_node_t node = {.project = project,
.label = "Function",
.name = "someIndexedSymbol",
.qualified_name = qualified_name,
.file_path = "mod.py",
.start_line = 1,
.end_line = 4};
ASSERT_GT(cbm_store_upsert_node(st, &node), 0);
const char *hit = "someIndexedSymbol";
bool ok = true;
bool reminded = false;
char diag[256] = "";
/* Reminders at 3, 6 and 12 searches; session b interleaves. */
for (int i = 1; ok && i <= 12; i++) {
bool b_reminded = false;
if (i <= 3) {
ok = test_drift_search(srv, "drift-test-b", "", hit, &b_reminded) &&
b_reminded == (i == 3);
}
ok = ok && test_drift_search(srv, "drift-test-a", "", hit, &reminded) &&
reminded == (i == 3 || i == 6 || i == 12);
if (!ok)
snprintf(diag, sizeof(diag), "session a search %d reminded=%d", i, reminded);
}
/* A graph call resets; the plugin tool name resets too. */
for (int round = 0; ok && round < 2; round++) {
const char *tool = round == 0 ? "mcp__codebase-memory-mcp__trace_path"
: "mcp__plugin_codebase-memory-mcp_codebase-memory-mcp__"
"search_graph";
ok = test_drift_graph_call(srv, "drift-test-a", tool);
for (int i = 1; ok && i <= 3; i++) {
ok = test_drift_search(srv, "drift-test-a", "", hit, &reminded) && reminded == (i == 3);
}
if (!ok)
snprintf(diag, sizeof(diag), "reset round %d", round);
}
/* Another server's tool is no graph call: after a reset and one search it
* leaves the count alone, so the 3rd search still carries the line. */
ok = ok &&
test_drift_graph_call(srv, "drift-test-a", "mcp__codebase-memory-mcp__search_graph") &&
test_drift_search(srv, "drift-test-a", "", hit, &reminded) && !reminded &&
test_drift_graph_call(srv, "drift-test-a", "mcp__other__search") &&
test_drift_search(srv, "drift-test-a", "", hit, &reminded) && !reminded &&
test_drift_search(srv, "drift-test-a", "", hit, &reminded) && reminded;
if (!ok && !diag[0])
snprintf(diag, sizeof(diag), "foreign tool reset the count");
/* The 3rd search finds nothing, so the 4th carries the line; the next
* threshold is 6. */
bool expect_c[] = {false, false, false, true, false, true};
for (int i = 0; ok && i < 6; i++) {
bool augmented = test_drift_search(srv, "drift-test-c", "",
i == 2 ? "zzzNotIndexedAnywhere" : hit, &reminded);
ok = augmented == (i != 2) && reminded == expect_c[i];
if (!ok)
snprintf(diag, sizeof(diag), "session c search %d augmented=%d reminded=%d", i + 1,
augmented, reminded);
}
/* Untracked: Codex, and no session_id. */
for (int i = 1; ok && i <= 6; i++) {
ok =
test_drift_search(srv, "drift-test-codex", "\"turn_id\":\"turn-1\",", hit, &reminded) &&
!reminded;
char input[512];
snprintf(input, sizeof(input),
"{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Grep\","
"\"cwd\":\"/tmp/hookproj\",\"tool_input\":{\"pattern\":\"%s\"}}",
hit);
char *output = cbm_hook_augment_process(srv, input);
ok = ok && output && !strstr(output, "since its last codebase-memory-mcp tool call");
free(output);
if (!ok)
snprintf(diag, sizeof(diag), "untracked search %d", i);
}
cbm_mcp_server_free(srv);
free(project);
if (!ok) {
fprintf(stderr, "drift diag: %s\n", diag);
FAIL("drift reminder must fire at 3, 6, 12 searches and reset on a graph call");
}
PASS();
}
/* The hook-client fast path answers Copilot's PostToolUse before identity
* hashing when the augmenter could never respond: a shell command that is not
* a search, or a tool the augmenter does not read (VS Code's local hook parser
@@ -14761,7 +14894,8 @@ TEST(cli_upgrade_migrates_released_claude_hook_scripts) {
size_t registered =
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", TEST_CLAUDE_POST_MATCHER, 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++) {
registered += test_count_exec_hook(settings_root, "SessionStart", matchers[i], binary,
@@ -16006,7 +16140,7 @@ TEST(cli_upsert_claude_hook_fresh) {
ASSERT(strstr(data, "PreToolUse") != NULL);
ASSERT(strstr(data, "PostToolUse") != NULL);
ASSERT(strstr(data, "\"Grep|Glob|Bash\"") != NULL);
ASSERT(strstr(data, "\"Read\"") != NULL);
ASSERT(strstr(data, "\"" TEST_CLAUDE_POST_MATCHER "\"") != NULL);
ASSERT(strstr(data, "\"Grep|Glob|Read\"") == NULL);
ASSERT_EQ(test_count_substring(data, "cbm-code-discovery-gate"), 2U);
@@ -16478,7 +16612,8 @@ TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733) {
size_t owned =
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", TEST_CLAUDE_POST_MATCHER, 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++) {
owned += test_count_exec_hook(root, "SessionStart", matchers[i], binary_path,
@@ -16542,7 +16677,8 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) {
size_t before_owned =
test_count_exec_hook(installed_root, "PreToolUse", "Grep|Glob|Bash", binary_path,
"hook-augment") +
test_count_exec_hook(installed_root, "PostToolUse", "Read", binary_path, "hook-augment") +
test_count_exec_hook(installed_root, "PostToolUse", TEST_CLAUDE_POST_MATCHER, binary_path,
"hook-augment") +
test_count_exec_hook(installed_root, "SubagentStart", "*", binary_path, "hook-augment");
for (size_t i = 0U; i < sizeof(matchers) / sizeof(matchers[0]); i++) {
before_owned += test_count_exec_hook(installed_root, "SessionStart", matchers[i],
@@ -16576,6 +16712,104 @@ TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733) {
PASS();
}
/* Rewrite the current Claude PostToolUse matcher back to the previous "Read". */
static bool test_claude_settings_to_read_matcher(const char *settings_path) {
char *text = read_test_file_alloc(settings_path);
const char *found = text ? strstr(text, TEST_CLAUDE_POST_MATCHER) : NULL;
if (!found) {
free(text);
return false;
}
size_t prefix = (size_t)(found - text);
size_t total = strlen(text) - strlen(TEST_CLAUDE_POST_MATCHER) + strlen("Read") + 1U;
char *rewritten = malloc(total);
if (!rewritten) {
free(text);
return false;
}
snprintf(rewritten, total, "%.*sRead%s", (int)prefix, text,
found + strlen(TEST_CLAUDE_POST_MATCHER));
write_test_file(settings_path, rewritten);
free(rewritten);
free(text);
return true;
}
/* The Claude PostToolUse hook also matches the graph tools, whose calls reset
* the drift reminder (#509). An install that finds the previous Read-only
* entry replaces it with one current entry, and uninstall removes the
* previous one too. */
TEST(cli_claude_post_hook_matcher_migrates_from_read) {
char *saved_home = save_test_env("HOME");
char *saved_path = save_test_env("PATH");
char *saved_config = save_test_env("CLAUDE_CONFIG_DIR");
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-hook-read-matcher-XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
char config_dir[512];
char settings_path[640];
char install_dir[512];
char binary_path[640];
snprintf(config_dir, sizeof(config_dir), "%s/.claude", tmpdir);
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir);
snprintf(install_dir, sizeof(install_dir), "%s/bin", tmpdir);
#ifdef _WIN32
snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", install_dir);
#else
snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", install_dir);
#endif
test_mkdirp(config_dir);
test_mkdirp(install_dir);
test_mark_claude_code_installed(tmpdir);
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("PATH", tmpdir, 1);
cbm_unsetenv("CLAUDE_CONFIG_DIR");
size_t counts[4] = {0U, 0U, 0U, 0U};
int install_rc[2] = {-1, -1};
for (int round = 0; round < 2; round++) {
install_rc[round] = cbm_install_agent_configs(tmpdir, binary_path, false, false);
char *settings = read_test_file_alloc(settings_path);
yyjson_doc *doc = settings ? yyjson_read(settings, strlen(settings), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
counts[round * 2] = test_count_exec_hook(root, "PostToolUse", TEST_CLAUDE_POST_MATCHER,
binary_path, "hook-augment");
counts[round * 2 + 1] =
test_count_exec_hook(root, "PostToolUse", "Read", binary_path, "hook-augment");
yyjson_doc_free(doc);
free(settings);
if (round == 0 && !test_claude_settings_to_read_matcher(settings_path))
break;
}
bool rewritten = test_claude_settings_to_read_matcher(settings_path);
char dir_arg[640];
snprintf(dir_arg, sizeof(dir_arg), "--dir=%s", install_dir);
char *argv[] = {"uninstall", "--yes", dir_arg};
int uninstall_rc = cli_test_cmd_uninstall(3, argv);
char *uninstalled = read_test_file_alloc(settings_path);
size_t after_owned = test_count_substring(uninstalled ? uninstalled : "", "\"hook-augment\"");
free(uninstalled);
restore_test_env("HOME", saved_home);
restore_test_env("PATH", saved_path);
restore_test_env("CLAUDE_CONFIG_DIR", saved_config);
test_rmdir_r(tmpdir);
if (install_rc[0] != 0 || install_rc[1] != 0 || counts[0] != 1U || counts[1] != 0U ||
counts[2] != 1U || counts[3] != 0U || !rewritten || uninstall_rc != 0 ||
after_owned != 0U) {
fprintf(stderr,
"Claude matcher diag install=%d,%d current=%zu,%zu read=%zu,%zu rewritten=%d "
"uninstall=%d after=%zu\n",
install_rc[0], install_rc[1], counts[0], counts[2], counts[1], counts[3], rewritten,
uninstall_rc, after_owned);
FAIL("Claude install must migrate the Read-only PostToolUse hook, and uninstall must "
"remove it");
}
PASS();
}
/* issue #618: hook-augment was a structural no-op on Windows because its path
* guards required POSIX-style '/'-prefixed absolute paths, so a drive-letter
* cwd (C:/repo) was rejected before any search_graph query. The predicate must
@@ -16941,7 +17175,7 @@ TEST(cli_upsert_claude_hook_existing) {
ASSERT_NOT_NULL(data);
ASSERT(strstr(data, "\"Grep|Glob|Bash\"") != NULL);
ASSERT(strstr(data, "PostToolUse") != NULL);
ASSERT(strstr(data, "\"Read\"") != NULL);
ASSERT(strstr(data, "\"" TEST_CLAUDE_POST_MATCHER "\"") != NULL);
/* Existing hook preserved */
ASSERT(strstr(data, "Bash") != NULL);
ASSERT(strstr(data, "firewall") != NULL);
@@ -19084,6 +19318,7 @@ SUITE(cli) {
RUN_TEST(cli_hook_augment_codex_pretooluse_payload);
RUN_TEST(cli_hook_augment_copilot_posttooluse_search);
RUN_TEST(cli_hook_augment_copilot_posttooluse_noop_gate);
RUN_TEST(cli_hook_augment_drift_reminder_counts_searches);
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);
@@ -19169,6 +19404,7 @@ SUITE(cli) {
RUN_TEST(cli_windows_claude_hook_command_is_shell_portable);
RUN_TEST(cli_claude_hooks_use_exec_form_across_shells_issue1733);
RUN_TEST(cli_claude_exec_hooks_custom_dir_uninstall_issue1733);
RUN_TEST(cli_claude_post_hook_matcher_migrates_from_read);
RUN_TEST(cli_hook_augment_path_is_abs);
RUN_TEST(cli_hook_augment_deadline_breadcrumb_issue858);
RUN_TEST(cli_hook_augment_log_long_home_creates_no_cut_path);
+7
View File
@@ -154,6 +154,12 @@ TEST(skip_fast_e2e) {
ASSERT_TRUE(cbm_should_skip_dir("e2e", CBM_MODE_FAST));
PASS();
}
TEST(no_skip_fast_external) {
ASSERT_FALSE(cbm_should_skip_dir("external", CBM_MODE_FAST));
ASSERT_FALSE(cbm_should_skip_dir("external", CBM_MODE_MODERATE));
ASSERT_FALSE(cbm_should_skip_dir("shop-external-api", CBM_MODE_FAST));
PASS();
}
/* ── Suffix filters ────────────────────────────────────────────── */
@@ -2474,6 +2480,7 @@ SUITE(discover) {
RUN_TEST(skip_fast_assets);
RUN_TEST(skip_fast_3rdparty);
RUN_TEST(skip_fast_e2e);
RUN_TEST(no_skip_fast_external);
/* Suffix filters */
RUN_TEST(suffix_pyc);
+158
View File
@@ -6552,6 +6552,81 @@ TEST(extract_py_url_fold_no_fabrication_issue706) {
PASS();
}
/* Issue #523 follow-up: a Python string whose opening token is more than one
* quote -- a prefix (r"", u"", b"", Rb"", an f"" with no field) or a triple
* quote -- kept the prefix and the extra quotes in the URL (`f"http://..."`,
* `""http://...""`), so the HTTP_CALLS edge never matched a route. Each must
* yield the bare URL, positional or as `url=`. */
TEST(extract_py_prefixed_string_url_issue523) {
CBMFileResult *r = extract("import requests\n"
"def f_plain():\n"
" return requests.get(f\"http://svc/v2/health\")\n"
"def raw():\n"
" return requests.get(r\"http://svc/v2/health\")\n"
"def uni():\n"
" return requests.get(u\"http://svc/v2/health\")\n"
"def byt():\n"
" return requests.get(b\"http://svc/v2/health\")\n"
"def raw_bytes():\n"
" return requests.get(Rb\"http://svc/v2/health\")\n"
"def triple():\n"
" return requests.get(\"\"\"http://svc/v2/health\"\"\")\n"
"def triple_single():\n"
" return requests.get('''http://svc/v2/health''')\n"
"def f_triple():\n"
" return requests.get(f\"\"\"http://svc/v2/health\"\"\")\n"
"def kw():\n"
" return requests.get(url=r\"http://svc/v2/health\")\n",
CBM_LANG_PYTHON, "t", "client.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_STR_EQ(url_in(r, "requests.get", "f_plain"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "raw"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "uni"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "byt"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "raw_bytes"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "triple"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "triple_single"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "f_triple"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "kw"), "http://svc/v2/health");
/* A wrapped literal is still a fully known value, so it is exact. */
const CBMCall *raw = find_call_in_func(r, "requests.get", "raw");
ASSERT_NOT_NULL(raw);
ASSERT_GTE(raw->arg_count, 1);
ASSERT_NOT_NULL(raw->args[0].value);
ASSERT_STR_EQ(raw->args[0].value, "http://svc/v2/health");
cbm_free_result(r);
PASS();
}
/* Same issue, the controls: a plain one-quote literal keeps the literal path
* unchanged, and an interpolating f-string still folds its field to "{}". */
TEST(extract_py_prefixed_string_url_controls_issue523) {
CBMFileResult *r = extract("import requests\n"
"def plain():\n"
" return requests.get(\"http://svc/v2/health\")\n"
"def plain_single():\n"
" return requests.get('http://svc/v2/health')\n"
"def field(order_id):\n"
" return requests.get(f\"http://svc/v2/orders/{order_id}\")\n"
"def raw_field(order_id):\n"
" return requests.get(rf\"http://svc/v2/orders/{order_id}\")\n",
CBM_LANG_PYTHON, "t", "ctl.py");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_STR_EQ(url_in(r, "requests.get", "plain"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "plain_single"), "http://svc/v2/health");
ASSERT_STR_EQ(url_in(r, "requests.get", "field"), "http://svc/v2/orders/{}");
ASSERT_STR_EQ(url_in(r, "requests.get", "raw_field"), "http://svc/v2/orders/{}");
cbm_free_result(r);
PASS();
}
/* Issue #1147: a frontend that composes every endpoint from module constants
* -- a base constant, template literals over it, and an object-literal map of
* endpoints -- extracted none of its API calls. Each template / concatenation
@@ -7360,6 +7435,85 @@ TEST(body_tokens_wide_body_samples_from_the_start) {
PASS();
}
/* The YAML walkers kept their pending mappings in fixed-size stacks (256 for
* the dotted key-path walk, 512 for the infrastructure-binding scan) and
* silently stopped pushing once full (#2388). A root mapping with more nested
* sibling mappings than the old cap lost the full dotted key_path of every
* leaf past it: `k299.url` was only ever recorded as the bare `url` that the
* per-mapping walk of the nested block emits. */
enum { WIDE_YAML_KEYS = 300, WIDE_YAML_ITEMS = 600 };
static int has_string_ref_path(CBMFileResult *r, const char *key_path, const char *value) {
for (int i = 0; i < r->string_refs.count; i++) {
const CBMStringRef *s = &r->string_refs.items[i];
if (s->key_path && s->value && strcmp(s->key_path, key_path) == 0 &&
strcmp(s->value, value) == 0) {
return 1;
}
}
return 0;
}
TEST(yaml_key_paths_wide_mapping_exceed_old_stack_cap) {
size_t cap = (size_t)WIDE_YAML_KEYS * 48 + 64;
char *src = malloc(cap);
ASSERT_NOT_NULL(src);
size_t pos = 0;
for (int i = 0; i < WIDE_YAML_KEYS; i++) {
pos += (size_t)snprintf(src + pos, cap - pos,
"k%d:\n url: \"https://h%d.example.com/x\"\n", i, i);
}
CBMFileResult *r = extract(src, CBM_LANG_YAML, "t", "wide.yaml");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
for (int i = 0; i < WIDE_YAML_KEYS; i++) {
char path[32];
char value[48];
snprintf(path, sizeof(path), "k%d.url", i);
snprintf(value, sizeof(value), "https://h%d.example.com/x", i);
ASSERT(has_string_ref_path(r, path, value));
}
cbm_free_result(r);
free(src);
PASS();
}
static int has_infra_source(CBMFileResult *r, const char *source, const char *url) {
for (int i = 0; i < r->infra_bindings.count; i++) {
const CBMInfraBinding *b = &r->infra_bindings.items[i];
if (b->source_name && b->target_url && strcmp(b->source_name, source) == 0 &&
strcmp(b->target_url, url) == 0) {
return 1;
}
}
return 0;
}
TEST(yaml_infra_bindings_wide_sequence_exceed_old_stack_cap) {
size_t cap = (size_t)WIDE_YAML_ITEMS * 64 + 64;
char *src = malloc(cap);
ASSERT_NOT_NULL(src);
size_t pos = 0;
for (int i = 0; i < WIDE_YAML_ITEMS; i++) {
pos += (size_t)snprintf(src + pos, cap - pos,
"- topic: t%d\n push_endpoint: \"https://h%d.example.com/p\"\n", i,
i);
}
CBMFileResult *r = extract(src, CBM_LANG_YAML, "t", "pubsub.yaml");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
for (int i = 0; i < WIDE_YAML_ITEMS; i++) {
char source[16];
char url[48];
snprintf(source, sizeof(source), "t%d", i);
snprintf(url, sizeof(url), "https://h%d.example.com/p", i);
ASSERT(has_infra_source(r, source, url));
}
cbm_free_result(r);
free(src);
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Perl call-graph noise (#459 follow-up)
* ═══════════════════════════════════════════════════════════════════ */
@@ -13582,6 +13736,8 @@ SUITE(extraction) {
RUN_TEST(extract_ts_template_base_url_no_literal_path_issue2291);
RUN_TEST(extract_py_concat_const_url_issue706);
RUN_TEST(extract_py_url_fold_no_fabrication_issue706);
RUN_TEST(extract_py_prefixed_string_url_issue523);
RUN_TEST(extract_py_prefixed_string_url_controls_issue523);
RUN_TEST(extract_ts_const_template_url_issue1147);
RUN_TEST(extract_ts_url_fold_no_fabrication_issue1147);
RUN_TEST(extract_ts_folded_topics_keep_raw_identity);
@@ -13619,6 +13775,8 @@ SUITE(extraction) {
RUN_TEST(complexity_wide_body_exceeds_old_stack_cap);
RUN_TEST(count_branching_wide_body_exceeds_old_stack_cap);
RUN_TEST(body_tokens_wide_body_samples_from_the_start);
RUN_TEST(yaml_key_paths_wide_mapping_exceed_old_stack_cap);
RUN_TEST(yaml_infra_bindings_wide_sequence_exceed_old_stack_cap);
RUN_TEST(extract_c_ifdef_split_brace_fn_recovered_issue961);
RUN_TEST(extract_cpp_preproc_signature_gap_issue946);
RUN_TEST(extract_cpp_preproc_macro_generated_callable_skipped_issue949);
+34
View File
@@ -1553,6 +1553,39 @@ TEST(contract_edge_infra_routes_from_deploy_configs_still_minted) {
PASS();
}
/* #2388: the YAML key-path walk kept its pending mappings in a 256-frame stack
* and stopped pushing once full. A `data:` payload block with more nested
* entries than that lost the full `data.<entry>.webhook_url` path of every entry
* past the cap; only the shorter per-mapping refs survived, the payload deny
* never saw them, and each of those values minted an infra Route (44 of 300
* here). Every payload value must stay unminted however wide the block is. */
enum { WIDE_PAYLOAD_ENTRIES = 300 };
TEST(contract_edge_no_infra_routes_from_wide_payload_block_issue2388) {
size_t cap = (size_t)WIDE_PAYLOAD_ENTRIES * 64 + 16;
char *src = malloc(cap);
ASSERT_NOT_NULL(src);
size_t pos = (size_t)snprintf(src, cap, "data:\n");
for (int i = 0; i < WIDE_PAYLOAD_ENTRIES; i++) {
pos +=
(size_t)snprintf(src + pos, cap - pos,
" svc%d:\n webhook_url: https://h%d.payload.example/hook\n", i, i);
}
LangProj lp;
LangFile f[] = {{"service.yaml", src}};
cbm_store_t *store = lang_index_files(&lp, f, 1);
ASSERT_NOT_NULL(store);
int routes = count_infra_routes_matching(store, lp.project, "payload.example");
if (routes != 0) {
fprintf(stderr, " [2388] FAIL wide payload block minted %d infra routes (expected 0)\n",
routes);
}
ASSERT_EQ(routes, 0);
lang_cleanup(&lp, store);
free(src);
PASS();
}
/* A config URL becomes an infra Route only under a key that names an endpoint
* this service exposes. Reference links are not: across 17 infra repositories
* the infra Routes were changelog `pull_request` links (deckhouse 6,240),
@@ -1945,6 +1978,7 @@ SUITE(lang_contract) {
RUN_TEST(contract_edge_python_aliased_import_call_resolves_issue988);
RUN_TEST(contract_edge_no_infra_routes_from_ci_configs_issue999);
RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted);
RUN_TEST(contract_edge_no_infra_routes_from_wide_payload_block_issue2388);
RUN_TEST(contract_edge_no_infra_routes_from_reference_links);
RUN_TEST(contract_edge_infra_routes_from_endpoint_key_spellings);
RUN_TEST(contract_edge_commonjs_require_call_resolves_issue871);
+675
View File
@@ -4511,6 +4511,281 @@ TEST(tool_search_graph_bm25_applies_label_filter) {
PASS();
}
/* #2386: the label filter must choose the BM25 candidate window, not trim it
* afterwards. 2001 Functions fill the window ahead of 3 Classes that rank
* strictly lower (longer file_path), so a post-window filter returns 0 Classes
* while reporting a saturated window. */
static char *bm25_label_window_search(cbm_mcp_server_t *srv, const char *label_json) {
char req[512];
snprintf(req, sizeof(req),
"{\"jsonrpc\":\"2.0\",\"id\":2386,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"bm25-label-window\",\"query\":\"windowneedle\","
"\"label\":%s,\"limit\":10,\"format\":\"json\"}}}",
label_json);
char *resp = cbm_mcp_server_handle(srv, req);
char *inner = resp ? extract_text_content(resp) : NULL;
free(resp);
return inner;
}
TEST(tool_search_graph_bm25_label_filter_selects_candidate_window) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
const char *project = "bm25-label-window";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/bm25-label-window"), CBM_STORE_OK);
for (int i = 0; i < 2001; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "bm25-label-window.fn_%04d", i);
cbm_node_t node = {.project = project,
.label = "Function",
.name = "windowneedle",
.qualified_name = qn,
.file_path = "many.c",
.start_line = i + 1,
.end_line = i + 1};
ASSERT_GT(cbm_store_upsert_node(store, &node), 0);
}
for (int i = 0; i < 3; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "bm25-label-window.cls_%d", i);
cbm_node_t node = {.project = project,
.label = "Class",
.name = "windowneedle",
.qualified_name = qn,
.file_path = "deeply/nested/package/layout/registry.c",
.start_line = i + 1,
.end_line = i + 1};
ASSERT_GT(cbm_store_upsert_node(store, &node), 0);
}
ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"),
CBM_STORE_OK);
ASSERT_EQ(cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, "
"file_path) SELECT id, cbm_camel_split(name), qualified_name, "
"label, file_path FROM nodes;"),
CBM_STORE_OK);
/* The rare label comes back in full, and the window is not saturated. */
char *inner = bm25_label_window_search(srv, "\"Class\"");
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"bm25-label-window.cls_0\""));
ASSERT_NOT_NULL(strstr(inner, "\"bm25-label-window.cls_2\""));
ASSERT_NULL(strstr(inner, "\"Function\""));
ASSERT_NOT_NULL(strstr(inner, "\"total\":3"));
ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\""));
ASSERT_NULL(strstr(inner, "candidate_window_saturated"));
free(inner);
/* The common label still overflows its own window and says so. */
inner = bm25_label_window_search(srv, "\"Function\"");
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"total\":2000"));
ASSERT_NOT_NULL(strstr(inner, "\"candidate_window_saturated\":true"));
free(inner);
/* A label carrying FTS5 syntax is never spliced into MATCH: the query
* stays in BM25 mode and the exact label check matches nothing. */
inner = bm25_label_window_search(srv, "\"Class\\\" OR windowneedle\"");
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\""));
ASSERT_NOT_NULL(strstr(inner, "\"total\":0"));
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* Scoping the MATCH to a label must not reorder that label's rows: a
* label-filtered query lists its rows in the same order as the unfiltered
* query does. The fixture's four `table` Methods differ in row length, which
* is exactly what a scored label phrase would re-weight. */
TEST(tool_search_graph_bm25_label_scope_keeps_rank_order) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
cbm_mcp_server_set_project(srv, "bm25-find");
bm25_findability_fixture(store, "bm25-find");
char *all_resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":2387,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"bm25-find\",\"query\":\"table\",\"limit\":20,\"format\":\"json\"}}}");
char *methods_resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":2388,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"bm25-find\",\"query\":\"table\",\"label\":\"Method\","
"\"limit\":20,\"format\":\"json\"}}}");
ASSERT_NOT_NULL(all_resp);
ASSERT_NOT_NULL(methods_resp);
char *all = extract_text_content(all_resp);
char *methods = extract_text_content(methods_resp);
free(all_resp);
free(methods_resp);
ASSERT_NOT_NULL(all);
ASSERT_NOT_NULL(methods);
ASSERT_NOT_NULL(strstr(methods, "\"total\":4"));
static const char *const qns[] = {
"\"bm25-find.core.Table.Table.unquoted\"",
"\"bm25-find.core.Table.Table.describe\"",
"\"bm25-find.tests.SchemaTests.table_references_table_with_same_name\"",
"\"bm25-find.tests.SchemaTests.table_references_table_with_same_name_mysql\"",
};
enum { QN_COUNT = sizeof(qns) / sizeof(qns[0]) };
for (int i = 0; i < QN_COUNT; i++) {
for (int j = 0; j < QN_COUNT; j++) {
if (i == j) {
continue;
}
const char *ai = strstr(all, qns[i]);
const char *aj = strstr(all, qns[j]);
const char *mi = strstr(methods, qns[i]);
const char *mj = strstr(methods, qns[j]);
ASSERT_NOT_NULL(ai);
ASSERT_NOT_NULL(aj);
ASSERT_NOT_NULL(mi);
ASSERT_NOT_NULL(mj);
ASSERT_EQ(ai < aj, mi < mj);
}
}
free(all);
free(methods);
cbm_mcp_server_free(srv);
PASS();
}
/* An unlabelled BM25 query never ranks File, Folder, Variable or Project nodes
* (they match almost any token through their path), but an explicit label
* naming one of them must be served: those nodes are in nodes_fts, and
* excluding them there too answered `query=config label=Folder` with 0 rows
* and an exact "eq" total. One node per label shares the query tokens with a
* Function. */
static void bm25_unranked_labels_fixture(cbm_store_t *store, const char *project) {
cbm_store_upsert_project(store, project, "/tmp/bm25-noise");
struct {
const char *label, *name, *qn, *file;
} rows[] = {
{"Function", "loadconfig", "bm25-noise.app.loadconfig", "app/config.py"},
{"File", "__file__", "bm25-noise.app.config.py", "app/config.py"},
{"Folder", "config", "bm25-noise.app.config", "app/config"},
{"Variable", "CONFIG_PATH", "bm25-noise.app.config.CONFIG_PATH", "app/config.py"},
{"Project", "bm25-noise", "bm25-noise", ""},
};
for (size_t i = 0; i < sizeof(rows) / sizeof(rows[0]); i++) {
cbm_node_t node = {.project = project,
.label = rows[i].label,
.name = rows[i].name,
.qualified_name = rows[i].qn,
.file_path = rows[i].file,
.start_line = (int)i + 1,
.end_line = (int)i + 1};
cbm_store_upsert_node(store, &node);
}
cbm_store_exec(store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');");
cbm_store_exec(store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, "
"file_path) SELECT id, cbm_camel_split(name), qualified_name, "
"label, file_path FROM nodes;");
}
static char *bm25_noise_search(cbm_mcp_server_t *srv, const char *query, const char *label) {
char req[512];
char label_arg[128] = "";
if (label) {
snprintf(label_arg, sizeof(label_arg), ",\"label\":\"%s\"", label);
}
snprintf(req, sizeof(req),
"{\"jsonrpc\":\"2.0\",\"id\":2423,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"bm25-noise\",\"query\":\"%s\"%s,\"limit\":10,\"format\":\"json\"}}}",
query, label_arg);
char *resp = cbm_mcp_server_handle(srv, req);
char *inner = resp ? extract_text_content(resp) : NULL;
free(resp);
return inner;
}
TEST(tool_search_graph_bm25_serves_unranked_labels_when_named) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
cbm_mcp_server_set_project(srv, "bm25-noise");
bm25_unranked_labels_fixture(store, "bm25-noise");
static const struct {
const char *label, *qn;
} named[] = {
{"File", "\"bm25-noise.app.config.py\",\"File\""},
{"Folder", "\"bm25-noise.app.config\",\"Folder\""},
{"Variable", "\"bm25-noise.app.config.CONFIG_PATH\",\"Variable\""},
{"Project", "\"bm25-noise\",\"Project\""},
};
for (size_t i = 0; i < sizeof(named) / sizeof(named[0]); i++) {
char *inner = bm25_noise_search(srv, "noise config", named[i].label);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"search_mode\":\"bm25\""));
ASSERT_NOT_NULL(strstr(inner, named[i].qn));
ASSERT_NOT_NULL(strstr(inner, "\"total\":1,"));
ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\""));
ASSERT_NULL(strstr(inner, "\"hint\""));
free(inner);
}
/* Without a label the four stay out of the ranking: only the Function. */
char *inner = bm25_noise_search(srv, "noise config", NULL);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"bm25-noise.app.loadconfig\",\"Function\""));
ASSERT_NOT_NULL(strstr(inner, "\"total\":1,"));
ASSERT_NULL(strstr(inner, "\"File\""));
ASSERT_NULL(strstr(inner, "\"Folder\""));
ASSERT_NULL(strstr(inner, "\"Variable\""));
ASSERT_NULL(strstr(inner, "\"Project\""));
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* Labels are case-sensitive. A BM25 query whose label no node carries as
* spelled answers 0 rows like the structural path does, but never silently:
* the hint names the stored spelling, or get_graph_schema when the label is
* unknown. An existing label with no matching row is a true zero: no hint. */
TEST(tool_search_graph_bm25_zero_hint_names_the_label) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
cbm_mcp_server_set_project(srv, "bm25-noise");
bm25_unranked_labels_fixture(store, "bm25-noise");
char *inner = bm25_noise_search(srv, "config", "folder");
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"total\":0,"));
ASSERT_NOT_NULL(strstr(inner, "\"hint\":"));
ASSERT_NOT_NULL(strstr(inner, "retry with label=\\\"Folder\\\""));
free(inner);
inner = bm25_noise_search(srv, "config", "Widget");
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"total\":0,"));
ASSERT_NOT_NULL(strstr(inner, "get_graph_schema"));
free(inner);
inner = bm25_noise_search(srv, "zzznomatch", "Function");
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"total\":0,"));
ASSERT_NULL(strstr(inner, "\"hint\""));
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* The definition whose NAME is the query ranks first: the `Table` class above
* its own methods and above test methods that repeat "table" three times; the
* `get_object_or_404` function above the test methods that contain it. */
@@ -7682,6 +7957,14 @@ TEST(tool_trace_budget_never_slices_identifiers) {
const char *project = "trace-byte-budget";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/trace-byte-budget"), CBM_STORE_OK);
cbm_coverage_meta_t coverage_meta = {.generation = "fixture",
.index_mode = "full",
.recorded_at = "2026-09-24T00:00:00Z",
.recording_status = "complete",
.coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION,
.unresolved_calls_complete = true,
.hash_records_complete = true};
ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK);
cbm_node_t hub = {.project = project,
.label = "Function",
@@ -8027,6 +8310,14 @@ TEST(tool_trace_reports_engine_saturation_as_lower_bound) {
const char *project = "trace-engine-cap";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/trace-engine-cap"), CBM_STORE_OK);
cbm_coverage_meta_t coverage_meta = {.generation = "fixture",
.index_mode = "full",
.recorded_at = "2026-09-24T00:00:00Z",
.recording_status = "complete",
.coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION,
.unresolved_calls_complete = true,
.hash_records_complete = true};
ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, NULL, 0, &coverage_meta), CBM_STORE_OK);
cbm_node_t hub = {.project = project,
.label = "Function",
.name = "hub",
@@ -8518,6 +8809,344 @@ TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped) {
PASS();
}
/* Unrelated file evidence must not poison a clean caller's exact outbound
* count. Inbound checks use their materialized candidate index, so corrupting
* an unrelated JSON payload after publication must not trigger a full scan. */
TEST(tool_trace_path_reads_only_relevant_unresolved_evidence) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
const char *project = "bounded-trace";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/bounded-trace"), CBM_STORE_OK);
cbm_node_t root = {.project = project,
.label = "Function",
.name = "run",
.qualified_name = "bounded-trace.run",
.file_path = "clean.js",
.start_line = 1,
.end_line = 4};
ASSERT_GT(cbm_store_upsert_node(store, &root), 0);
ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "unrelated.js", "fixture", 0, 0),
CBM_STORE_OK);
cbm_coverage_row_t row = {.rel_path = "unrelated.js",
.kind = "unresolved_calls",
.detail = "[{\"caller\":\"bounded-trace.other\",\"leaf\":\"other\","
"\"candidate\":\"bounded-trace.other\"}]"};
cbm_coverage_meta_t meta = {.generation = "fixture",
.index_mode = "full",
.recording_status = "complete",
.coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION,
.unresolved_calls_complete = true,
.hash_records_complete = true};
ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, &row, 1, &meta), CBM_STORE_OK);
ASSERT_EQ(cbm_store_exec(store, "UPDATE index_coverage SET detail='broken json' "
"WHERE project='bounded-trace';"),
CBM_STORE_OK);
const char *directions[] = {"outbound", "inbound"};
const char *fields[] = {"callees_total_relation", "callers_total_relation"};
for (int i = 0; i < 2; i++) {
char args[256], expected[80];
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"function_name\":\"run\","
"\"direction\":\"%s\",\"format\":\"json\"}",
project, directions[i]);
char *out = cbm_mcp_handle_tool(srv, "trace_path", args);
ASSERT_NOT_NULL(out);
snprintf(expected, sizeof(expected), "\"%s\":\"eq\"", fields[i]);
ASSERT(response_contains_json_fragment(out, expected));
free(out);
}
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_path_truncated_evidence_is_conservative) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
const char *project = "truncated-trace";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/truncated-trace"), CBM_STORE_OK);
cbm_node_t root = {.project = project, .label = "Function", .name = "run",
.qualified_name = "truncated-trace.run", .file_path = "clean.js",
.start_line = 1, .end_line = 4};
ASSERT_GT(cbm_store_upsert_node(store, &root), 0);
ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "unrelated.js", "fixture", 0, 0), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "clean.js", "fixture", 0, 0), CBM_STORE_OK);
cbm_coverage_meta_t meta = {.generation = "fixture", .index_mode = "full",
.recording_status = "complete", .coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION,
.unresolved_calls_complete = true, .hash_records_complete = true};
const char *details[] = {"[{\"truncated\":true}]", "[null]", "broken json"};
const char *directions[] = {"outbound", "inbound"};
const char *fields[] = {"callees_total_relation", "callers_total_relation"};
for (size_t detail = 0; detail < sizeof(details) / sizeof(details[0]); detail++) {
for (int relevant = 0; relevant < 2; relevant++) {
cbm_coverage_row_t row = {.rel_path = relevant ? "clean.js" : "unrelated.js",
.kind = "unresolved_calls", .detail = details[detail]};
ASSERT_EQ(cbm_store_coverage_replace_ex(store, project, &row, 1, &meta), CBM_STORE_OK);
for (int direction = 0; direction < 2; direction++) {
char args[256], expected[80];
snprintf(args, sizeof(args), "{\"project\":\"%s\",\"function_name\":\"run\","
"\"direction\":\"%s\",\"format\":\"json\"}", project, directions[direction]);
char *out = cbm_mcp_handle_tool(srv, "trace_path", args);
ASSERT_NOT_NULL(out);
snprintf(expected, sizeof(expected), "\"%s\":\"%s\"", fields[direction],
relevant || direction == 1 ? "unknown" : "eq");
ASSERT(response_contains_json_fragment(out, expected));
free(out);
}
}
}
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_path_marks_unresolved_call_totals_unknown) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "unresolved-trace";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/unresolved-trace");
cbm_node_t caller = {.project = proj,
.label = "Function",
.name = "run",
.qualified_name = "unresolved-trace.service.run",
.file_path = "service.js",
.start_line = 1,
.end_line = 4};
cbm_node_t callee = {.project = proj,
.label = "Function",
.name = "buscar",
.qualified_name = "unresolved-trace.client.buscar",
.file_path = "client.js",
.start_line = 1,
.end_line = 2};
ASSERT_GT(cbm_store_upsert_node(st, &caller), 0);
ASSERT_GT(cbm_store_upsert_node(st, &callee), 0);
ASSERT_EQ(cbm_store_upsert_file_hash(st, proj, "service.js", "fixture", 0, 0), CBM_STORE_OK);
char *out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":"
"\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\","
"\"format\":\"json\"}}}");
char *txt = extract_text_content(out);
ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"unknown\""));
free(txt);
free(out);
cbm_coverage_row_t row = {
.rel_path = "service.js",
.kind = "unresolved_calls",
.detail = "[{\"caller\":\"unresolved-trace.service.run\",\"leaf\":\"buscar\","
"\"start_byte\":42,\"end_byte\":59,\"line\":3,"
"\"candidate\":\"unresolved-trace.client.buscar\","
"\"reason\":\"method_not_in_registry\"}]"};
cbm_coverage_meta_t meta = {.generation = "fixture",
.index_mode = "full",
.recorded_at = "2026-09-24T00:00:00Z",
.recording_status = "complete",
.coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION,
.unresolved_calls_complete = true,
.hash_records_complete = true};
ASSERT_EQ(cbm_store_coverage_replace_ex(st, proj, &row, 1, &meta), CBM_STORE_OK);
out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":"
"\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\","
"\"format\":\"json\"}}}");
txt = extract_text_content(out);
ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"unknown\""));
free(txt);
free(out);
out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":"
"\"unresolved-trace\",\"function_name\":\"buscar\",\"direction\":\"inbound\","
"\"format\":\"json\"}}}");
txt = extract_text_content(out);
ASSERT_NOT_NULL(strstr(txt, "\"callers_total_relation\":\"unknown\""));
free(txt);
free(out);
/* The same leaf in an unrelated module was never a resolver candidate. */
cbm_node_t unrelated = callee;
unrelated.qualified_name = "unresolved-trace.other.buscar";
unrelated.file_path = "other.js";
ASSERT_GT(cbm_store_upsert_node(st, &unrelated), 0);
out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":"
"\"unresolved-trace\",\"function_name\":\"unresolved-trace.other.buscar\","
"\"direction\":\"inbound\",\"format\":\"json\"}}}");
txt = extract_text_content(out);
ASSERT_NOT_NULL(strstr(txt, "\"callers_total_relation\":\"eq\""));
free(txt);
free(out);
out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\","
"\"params\":{\"name\":\"check_index_coverage\",\"arguments\":{\"project\":"
"\"unresolved-trace\",\"paths\":[\"service.js\"],\"diagnostics\":\"full\","
"\"format\":\"json\"}}}");
txt = extract_text_content(out);
ASSERT_NOT_NULL(strstr(txt, "\"status\":\"unresolved_calls\""));
ASSERT_NOT_NULL(strstr(txt, "method_not_in_registry"));
free(txt);
free(out);
out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":"
"\"unresolved-trace\",\"function_name\":\"run\",\"direction\":\"outbound\","
"\"edge_types\":[\"IMPORTS\"],\"format\":\"json\"}}}");
txt = extract_text_content(out);
ASSERT_NOT_NULL(strstr(txt, "\"callees_total_relation\":\"eq\""));
free(txt);
free(out);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_path_outbound_requires_project_symbol) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "project-symbol-trace";
cbm_mcp_server_set_project(srv, proj);
ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/project-symbol-trace"), CBM_STORE_OK);
cbm_node_t caller = {.project = proj,
.label = "Function",
.name = "run",
.qualified_name = "project-symbol-trace.service.run",
.file_path = "service.js",
.start_line = 1,
.end_line = 4};
cbm_node_t local = {.project = proj,
.label = "Function",
.name = "buscar",
.qualified_name = "project-symbol-trace.client.buscar",
.file_path = "client.js"};
cbm_node_t constructor = {.project = proj,
.label = "Class",
.name = "Cliente",
.qualified_name = "project-symbol-trace.client.Cliente",
.file_path = "client.js"};
cbm_node_t foreign = {.project = "other-project",
.label = "Function",
.name = "foreignOnly",
.qualified_name = "other-project.client.foreignOnly",
.file_path = "client.js"};
ASSERT_GT(cbm_store_upsert_node(st, &caller), 0);
ASSERT_GT(cbm_store_upsert_node(st, &local), 0);
ASSERT_GT(cbm_store_upsert_node(st, &constructor), 0);
ASSERT_EQ(cbm_store_upsert_project(st, foreign.project, "/tmp/other-project"), CBM_STORE_OK);
ASSERT_GT(cbm_store_upsert_node(st, &foreign), 0);
const char *containers[] = {"File", "Folder", "Project", "Module", "Package", "Section"};
for (size_t i = 0; i < sizeof(containers) / sizeof(containers[0]); i++) {
char name[64], qn[128];
snprintf(name, sizeof(name), "external%s", containers[i]);
snprintf(qn, sizeof(qn), "%s.%s", proj, name);
cbm_node_t container = {
.project = proj, .label = containers[i], .name = name, .qualified_name = qn};
ASSERT_GT(cbm_store_upsert_node(st, &container), 0);
}
ASSERT_EQ(cbm_store_upsert_file_hash(st, proj, "service.js", "fixture", 0, 0), CBM_STORE_OK);
cbm_coverage_meta_t meta = {.generation = "fixture",
.index_mode = "full",
.recorded_at = "2026-09-29T00:00:00Z",
.recording_status = "complete",
.coverage_version = CBM_UNRESOLVED_CALL_COVERAGE_VERSION,
.unresolved_calls_complete = true,
.hash_records_complete = true};
const struct {
const char *leaf;
const char *relation;
} cases[] = {{"printf", "eq"}, {"foreignOnly", "eq"}, {"Buscar", "eq"},
{"externalFile", "eq"}, {"externalFolder", "eq"}, {"externalProject", "eq"},
{"externalModule", "eq"}, {"externalPackage", "eq"}, {"externalSection", "eq"},
{"buscar", "unknown"}, {"Cliente", "unknown"}};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char detail[512];
snprintf(detail, sizeof(detail),
"[{\"caller\":\"project-symbol-trace.service.run\",\"leaf\":\"%s\","
"\"start_byte\":42,\"end_byte\":59,\"line\":3,"
"\"reason\":\"method_not_in_registry\"}]",
cases[i].leaf);
cbm_coverage_row_t row = {
.rel_path = "service.js", .kind = "unresolved_calls", .detail = detail};
ASSERT_EQ(cbm_store_coverage_replace_ex(st, proj, &row, 1, &meta), CBM_STORE_OK);
char *out = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_path\",\"arguments\":{\"project\":"
"\"project-symbol-trace\",\"function_name\":\"run\","
"\"direction\":\"outbound\",\"format\":\"json\"}}}");
char *txt = extract_text_content(out);
char expected[80];
snprintf(expected, sizeof(expected), "\"callees_total_relation\":\"%s\"",
cases[i].relation);
ASSERT_NOT_NULL(strstr(txt, expected));
ASSERT_NOT_NULL(strstr(txt, "\"callees_total\":0"));
free(txt);
free(out);
}
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_index_unresolved_nested_functions_are_indexed) {
char tmp[256] = "/tmp/cbm-mcp-unresolved-XXXXXX";
char cache[256] = "/tmp/cbm-mcp-unresolved-cache-XXXXXX";
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char path[512];
snprintf(path, sizeof(path), "%s/service.js", tmp);
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs("export class Client { buscar(id) { return id; } }\n"
"export function factory({ client }) {\n"
" function inner(id) {\n"
" return client.buscar(id);\n"
" }\n return { inner };\n}\n",
f);
fclose(f);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[1024];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", tmp);
char *out = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(out);
ASSERT(response_contains_json_fragment(out, "\"status\":\"indexed\""));
ASSERT(response_contains_json_fragment(out, "\"skipped_count\":0"));
free(out);
char *project = cbm_project_name_from_path(tmp);
ASSERT_NOT_NULL(project);
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"function_name\":\"inner\","
"\"direction\":\"outbound\",\"format\":\"json\"}",
project);
out = cbm_mcp_handle_tool(srv, "trace_path", args);
ASSERT_NOT_NULL(out);
ASSERT(response_contains_json_fragment(out, "\"callees_total_relation\":\"unknown\""));
free(out);
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"function_name\":\"factory\","
"\"direction\":\"outbound\",\"format\":\"json\"}",
project);
out = cbm_mcp_handle_tool(srv, "trace_path", args);
ASSERT_NOT_NULL(out);
ASSERT(response_contains_json_fragment(out, "\"callees_total_relation\":\"eq\""));
free(out);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
restore_cache_dir(saved_copy);
free(saved_copy);
free(project);
remove(path);
cbm_rmdir(tmp);
cbm_rmdir(cache);
PASS();
}
/* Edge-data lookup must follow the traversal direction and shortest-path hop.
* The induced edge set contains a same-hop cross edge whose source is the row
* node and sorts before its real inbound predecessor. An arbitrary incident-
@@ -24551,6 +25180,42 @@ TEST(search_code_rejects_quote_in_file_pattern) {
PASS();
}
TEST(tool_check_index_coverage_parse_gap_precedes_unresolved_calls) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
const char *project = "coverage-priority";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/coverage-priority"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(store, project, "mixed.js", "fixture", 0, 0),
CBM_STORE_OK);
const char *kinds[] = {"parse_partial", "parse_unusable", "oversized", "not_indexed_file"};
const char *statuses[] = {"partial", "unusable", "skipped", "excluded"};
for (size_t i = 0; i < sizeof(kinds) / sizeof(kinds[0]); i++) {
cbm_coverage_row_t rows[] = {
{.rel_path = "mixed.js", .kind = "unresolved_calls", .detail = "[]"},
{.rel_path = "mixed.js", .kind = kinds[i], .detail = "1-2"},
};
ASSERT_EQ(cbm_store_coverage_replace(store, project, rows, 2), CBM_STORE_OK);
char *response = cbm_mcp_handle_tool(
srv, "check_index_coverage",
"{\"project\":\"coverage-priority\",\"paths\":[\"mixed.js\"],\"format\":\"json\"}");
ASSERT_NOT_NULL(response);
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *path = yyjson_arr_get(yyjson_obj_get(yyjson_doc_get_root(doc), "paths"), 0);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(path, "status")), statuses[i]);
ASSERT_EQ(yyjson_arr_size(yyjson_obj_get(path, "coverage")), 2);
yyjson_doc_free(doc);
free(inner);
free(response);
}
cbm_mcp_server_free(srv);
PASS();
}
/* ── search_code: every match resolves inside the project root ─────────
* search_code returns the lines its scan opened. The scoped file list is
* built from the index, and an indexed path that now passes through a
@@ -25108,6 +25773,7 @@ TEST(search_code_scan_in_process_limits_and_failures) {
#undef SCAN_RS
SUITE(mcp) {
RUN_TEST(tool_check_index_coverage_parse_gap_precedes_unresolved_calls);
RUN_TEST(index_repository_async_and_status_refused_without_daemon_issue2144);
RUN_TEST(index_repository_schema_documents_async_polling_issue2144);
RUN_TEST(tool_result_add_notice_keeps_payload_shape_issue2144);
@@ -25259,6 +25925,10 @@ SUITE(mcp) {
RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552);
RUN_TEST(tool_search_graph_bm25_reports_candidate_saturation);
RUN_TEST(tool_search_graph_bm25_applies_label_filter);
RUN_TEST(tool_search_graph_bm25_label_filter_selects_candidate_window);
RUN_TEST(tool_search_graph_bm25_label_scope_keeps_rank_order);
RUN_TEST(tool_search_graph_bm25_serves_unranked_labels_when_named);
RUN_TEST(tool_search_graph_bm25_zero_hint_names_the_label);
RUN_TEST(tool_search_graph_bm25_ranks_exact_name_first);
RUN_TEST(tool_search_graph_rejects_bm25_and_semantic_query_together);
RUN_TEST(tool_search_graph_semantic_ceiling_never_emits_unusable_continuation);
@@ -25308,6 +25978,11 @@ SUITE(mcp) {
RUN_TEST(tool_trace_call_path_qn_fallback_frees_name_miss);
RUN_TEST(trace_evidence_strategy_class_vocabulary_is_closed);
RUN_TEST(tool_trace_path_evidence_is_opt_in_and_class_mapped);
RUN_TEST(tool_trace_path_reads_only_relevant_unresolved_evidence);
RUN_TEST(tool_trace_path_truncated_evidence_is_conservative);
RUN_TEST(tool_trace_path_marks_unresolved_call_totals_unknown);
RUN_TEST(tool_trace_path_outbound_requires_project_symbol);
RUN_TEST(tool_index_unresolved_nested_functions_are_indexed);
RUN_TEST(tool_trace_path_evidence_columns_match_header_issue1542);
RUN_TEST(tool_trace_path_unreadable_confidence_reports_not_recorded);
RUN_TEST(tool_trace_path_edge_details_use_canonical_predecessor);
+487 -49
View File
@@ -770,6 +770,259 @@ TEST(pipeline_adr_survives_full_reindex) {
PASS();
}
static int pipeline_unresolved_review_case(int padding, bool spill) {
char tmp[256] = "/tmp/cbm_unresolved_review_XXXXXX";
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char db[512], path[512];
const char *names[] = {"cliente.js", "servicio.js", "ayudante.js",
"directo.js", "missing.js", "route.js"};
const char *sources[] = {
"export function crearCliente() { function buscar(id) { return id; } return { buscar }; "
"}\n",
"export function crearServicio({ cliente }) {\n"
" function procesar(id) {\n"
" cliente.buscar(id); return cliente.buscar(id + 1);\n"
" }\n return { procesar };\n}\n",
"export function ayudante(id) { return id; }\n",
"import { ayudante } from './ayudante.js';\n"
"export function usarDirecto(id) { ayudante(id); return ayudante(id + 1); }\n",
"import { absent } from './ayudante.js';\n"
"export function missing(id) { return absent(id); }\n",
"export function setup({ app }) { app.get('/x', handler); }\n"
"function handler() { return 1; }\n"};
for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
snprintf(path, sizeof(path), "%s/%s", tmp, names[i]);
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs(sources[i], f);
fclose(f);
}
for (int i = 0; i < padding; i++) {
snprintf(path, sizeof(path), "%s/pad%d.js", tmp, i);
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fprintf(f, "export function pad%d() { return %d; }\n", i, i);
fclose(f);
}
snprintf(db, sizeof(db), "%s/index.db", tmp);
if (spill)
cbm_setenv("CBM_MEM_SPILL", "1", 1);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
int rc = cbm_pipeline_run(p);
if (spill)
cbm_unsetenv("CBM_MEM_SPILL");
ASSERT_EQ(rc, 0);
char project[256];
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p));
cbm_pipeline_free(p);
cbm_store_t *st = cbm_store_open_path(db);
ASSERT_NOT_NULL(st);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "directo.js", &rows, &count), CBM_STORE_OK);
for (int i = 0; i < count; i++)
ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0);
cbm_store_free_coverage(rows, count);
rows = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "route.js", &rows, &count), CBM_STORE_OK);
for (int i = 0; i < count; i++)
ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0);
cbm_store_free_coverage(rows, count);
rows = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
yyjson_doc *doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *sites = yyjson_doc_get_root(doc);
ASSERT_EQ(yyjson_arr_size(sites), 2);
for (size_t i = 0; i < 2; i++) {
yyjson_val *site = yyjson_arr_get(sites, i);
const char *caller = yyjson_get_str(yyjson_obj_get(site, "caller"));
ASSERT_NOT_NULL(caller);
ASSERT_NOT_NULL(strstr(caller, ".procesar"));
ASSERT_EQ(yyjson_get_int(yyjson_obj_get(site, "line")), 3);
ASSERT_NOT_NULL(yyjson_get_str(yyjson_obj_get(site, "candidate")));
}
yyjson_doc_free(doc);
cbm_store_free_coverage(rows, count);
rows = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "missing.js", &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0);
ASSERT_NOT_NULL(doc);
ASSERT_EQ(yyjson_arr_size(yyjson_doc_get_root(doc)), 1);
yyjson_doc_free(doc);
cbm_store_free_coverage(rows, count);
cbm_store_close(st);
rm_rf(tmp);
PASS();
}
/* A noisy semantic resolver must retain an explicit bounded-coverage marker,
* including when a single reason would exceed the entire detail budget. */
TEST(pipeline_unresolved_capture_bounds_count_and_json_bytes) {
char tmp[256] = "/tmp/cbm_unresolved_cap_XXXXXX";
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char db[512];
snprintf(db, sizeof(db), "%s/index.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
CBMResolvedCall *sites = calloc(1001, sizeof(*sites));
ASSERT_NOT_NULL(sites);
for (int i = 0; i < 1001; i++) {
sites[i] = (CBMResolvedCall){.kind = CBM_RESOLVED_INVOCATION,
.caller_qn = "cap.run",
.callee_qn = "missing",
.strategy = "lsp_unresolved",
.site_start_byte = (uint32_t)i * 2,
.site_end_byte = (uint32_t)i * 2 + 1};
}
CBMFileResult result = {.resolved_calls = {.items = sites, .count = 1001}};
cbm_pipeline_record_unresolved_calls(p, "many.js", &result, NULL);
cbm_coverage_row_t *rows = NULL;
int count = 0;
bool complete = false;
cbm_pipeline_get_unresolved_calls(p, &rows, &count, &complete);
ASSERT_TRUE(complete);
ASSERT_EQ(count, 1);
ASSERT_TRUE(strlen(rows[0].detail) <= 128 * 1024);
yyjson_doc *doc = yyjson_read(rows[0].detail, strlen(rows[0].detail), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *array = yyjson_doc_get_root(doc);
ASSERT_TRUE(yyjson_arr_size(array) <= 1001);
ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(yyjson_arr_get_last(array), "truncated")));
yyjson_doc_free(doc);
char *reason = malloc(200000);
ASSERT_NOT_NULL(reason);
memset(reason, '"', 199999);
reason[199999] = '\0';
sites[0].reason = reason;
result.resolved_calls.count = 1;
cbm_pipeline_record_unresolved_calls(p, "huge.js", &result, NULL);
cbm_pipeline_get_unresolved_calls(p, &rows, &count, &complete);
ASSERT_TRUE(complete);
ASSERT_EQ(count, 2);
ASSERT_TRUE(strlen(rows[1].detail) <= 128 * 1024);
doc = yyjson_read(rows[1].detail, strlen(rows[1].detail), 0);
ASSERT_NOT_NULL(doc);
array = yyjson_doc_get_root(doc);
ASSERT_EQ(yyjson_arr_size(array), 1);
ASSERT_TRUE(yyjson_get_bool(yyjson_obj_get(yyjson_arr_get_last(array), "truncated")));
yyjson_doc_free(doc);
free(reason);
free(sites);
cbm_pipeline_free(p);
rm_rf(tmp);
PASS();
}
TEST(pipeline_unresolved_review_sequential) {
return pipeline_unresolved_review_case(0, false);
}
TEST(pipeline_unresolved_review_parallel) {
return pipeline_unresolved_review_case(55, false);
}
TEST(pipeline_unresolved_review_spill) {
return pipeline_unresolved_review_case(55, true);
}
TEST(pipeline_records_unresolved_injected_call_sites) {
char tmp[256] = "/tmp/cbm_unresolved_calls_XXXXXX";
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char db_path[512], path[512];
snprintf(db_path, sizeof(db_path), "%s/test.db", tmp);
snprintf(path, sizeof(path), "%s/cliente.js", tmp);
FILE *f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs("export function crearCliente() { function buscar(id) { return id; } "
"return { buscar }; }\n",
f);
fclose(f);
snprintf(path, sizeof(path), "%s/servicio.js", tmp);
f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs("export function crearServicio({ cliente }) {\n"
" function procesar(id) { return cliente.buscar(id); }\n"
" return { procesar };\n}\n",
f);
fclose(f);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
char project[256];
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p));
cbm_pipeline_free(p);
cbm_store_t *st = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(st);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK);
bool found = false;
for (int i = 0; i < count; i++) {
if (strcmp(rows[i].kind, "unresolved_calls") == 0 && strstr(rows[i].detail, "buscar") &&
strstr(rows[i].detail, "method_not_in_registry")) {
found = true;
}
}
ASSERT_TRUE(found);
cbm_store_free_coverage(rows, count);
cbm_store_close(st);
/* Reindexing another file must retain this file's diagnostic. */
snprintf(path, sizeof(path), "%s/cliente.js", tmp);
f = fopen(path, "a");
ASSERT_NOT_NULL(f);
fputs("\n// unrelated edit\n", f);
fclose(f);
p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_pipeline_free(p);
st = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(st);
rows = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK);
found = false;
for (int i = 0; i < count; i++) {
found |= strcmp(rows[i].kind, "unresolved_calls") == 0;
}
ASSERT_TRUE(found);
cbm_store_free_coverage(rows, count);
cbm_store_close(st);
/* The edited file must lose its old diagnostic on the next generation. */
snprintf(path, sizeof(path), "%s/servicio.js", tmp);
f = fopen(path, "w");
ASSERT_NOT_NULL(f);
fputs(
"export function crearServicio({ cliente }) { return { procesar(id) { return id; } }; }\n",
f);
fclose(f);
p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_pipeline_free(p);
st = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(st);
rows = NULL;
count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(st, project, "servicio.js", &rows, &count), CBM_STORE_OK);
for (int i = 0; i < count; i++) {
ASSERT_FALSE(strcmp(rows[i].kind, "unresolved_calls") == 0);
}
cbm_store_free_coverage(rows, count);
cbm_store_close(st);
rm_rf(tmp);
PASS();
}
TEST(pipeline_structure_edges) {
if (setup_test_repo() != 0) {
FAIL("failed to create temp dir");
@@ -1693,7 +1946,8 @@ TEST(pipeline_incremental_drops_terraform_reads_of_a_moved_module) {
if (setup_test_repo() != 0) {
FAIL("failed to create temp dir");
}
write_temp_file(g_tmpdir, "infra/main.tf", "module \"net\" {\n source = \"./modules/net\"\n}\n");
write_temp_file(g_tmpdir, "infra/main.tf",
"module \"net\" {\n source = \"./modules/net\"\n}\n");
write_temp_file(g_tmpdir, "infra/outputs.tf",
"output \"net_vpc\" {\n value = module.net.vpc_id\n}\n");
write_temp_file(g_tmpdir, "infra/modules/net/main.tf",
@@ -3850,8 +4104,9 @@ TEST(incremental_shell_deploy_edit_keeps_its_edges) {
}
write_temp_file(tmp, "manifests/app.yaml",
"apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: app\n");
write_temp_file(tmp, "hack/deploy.sh",
"#!/bin/sh\ncd \"$(dirname \"$0\")/..\"\nkubectl apply -f manifests/app.yaml\n");
write_temp_file(
tmp, "hack/deploy.sh",
"#!/bin/sh\ncd \"$(dirname \"$0\")/..\"\nkubectl apply -f manifests/app.yaml\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/shinc.db", tmp);
static const char deploys[] = "SELECT COUNT(*) FROM edges WHERE type='DEPLOYS'";
@@ -3905,8 +4160,7 @@ static int incremental_spawner_delete(bool legacy, cbm_incremental_route_t *rout
snprintf(path, sizeof(path), "%s/hack/a.sh", tmp);
(void)remove(path);
if (!legacy) {
*after_script =
k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, processes) : -1;
*after_script = k8s_inc_index(tmp, db_path) == 0 ? k8s_inc_count(db_path, processes) : -1;
*route = cbm_pipeline_incremental_test_last_route();
}
snprintf(path, sizeof(path), "%s/app.py", tmp);
@@ -4152,7 +4406,8 @@ static char *incremental_env_edit(bool legacy, bool dockerfile, cbm_incremental_
return NULL;
}
write_temp_file(tmp, "Dockerfile",
dockerfile ? "FROM alpine:3.20\nENV OLD_D=1\n" : "FROM alpine:3.20\nENV KEEP_D=1\n");
dockerfile ? "FROM alpine:3.20\nENV OLD_D=1\n"
: "FROM alpine:3.20\nENV KEEP_D=1\n");
write_temp_file(tmp, "compose.yaml",
"services:\n web:\n image: acme/web\n environment:\n OLD_C: x\n");
write_temp_file(tmp, "compose.other.yaml",
@@ -5375,8 +5630,9 @@ static char *incremental_docker_build_keep(bool legacy, cbm_incremental_route_t
return NULL;
}
write_temp_file(tmp, "svc/Dockerfile", "FROM golang:1.22\n");
write_temp_file(tmp, "build.sh",
"#!/bin/sh\ncd \"$(dirname \"$0\")\"\ndocker build -t ghcr.io/acme/svc:1 svc\n");
write_temp_file(
tmp, "build.sh",
"#!/bin/sh\ncd \"$(dirname \"$0\")\"\ndocker build -t ghcr.io/acme/svc:1 svc\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/dockerbuild.db", tmp);
static const char builds[] =
@@ -5427,10 +5683,12 @@ TEST(incremental_second_build_declarer_keeps_the_edge) {
snprintf(tmp, sizeof(tmp), "/tmp/cbm_twodecl_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
write_temp_file(tmp, "svc/Dockerfile", "FROM golang:1.22\n");
write_temp_file(tmp, "a.sh", "#!/bin/sh\ncd \"$(dirname \"$0\")\"\n"
"docker build -t ghcr.io/acme/svc:1 svc\n");
write_temp_file(tmp, "b.sh", "#!/bin/sh\ncd \"$(dirname \"$0\")\"\n"
"docker build -t ghcr.io/acme/svc:2 svc\n");
write_temp_file(tmp, "a.sh",
"#!/bin/sh\ncd \"$(dirname \"$0\")\"\n"
"docker build -t ghcr.io/acme/svc:1 svc\n");
write_temp_file(tmp, "b.sh",
"#!/bin/sh\ncd \"$(dirname \"$0\")\"\n"
"docker build -t ghcr.io/acme/svc:2 svc\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/twodecl.db", tmp);
static const char builds[] =
@@ -5921,6 +6179,76 @@ TEST(pipeline_closure_repair_removed_def_drops_dependent_edge) {
PASS();
}
/* #2276: the closure/delta route clones the previous generation's db file
* byte-for-byte and patches only nodes/edges/hashes/surfaces, so a reindex
* that keeps the same project name but a DIFFERENT repo_path (an artifact
* moved or reused from another checkout) leaves the `projects` row's
* root_path/indexed_at pinned to whichever directory the last FULL rebuild
* ran from, so every later root_path-keyed read then resolves files against
* a directory this run never touched. */
TEST(pipeline_incremental_root_path_refreshed_across_directories) {
char dir_a[256];
char dir_b[256];
snprintf(dir_a, sizeof(dir_a), "/tmp/cbm_rootpath_a_XXXXXX");
snprintf(dir_b, sizeof(dir_b), "/tmp/cbm_rootpath_b_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(dir_a));
ASSERT_NOT_NULL(cbm_mkdtemp(dir_b));
closure_probe_repo(dir_a);
closure_probe_repo(dir_b);
/* Body edit only, same exported names: the shape the closure route
* repairs without falling back to a full rebuild (mirrors
* pipeline_closure_repair_body_edit_converges_with_fresh_full above). */
write_temp_file(dir_b, "lib.ts",
"export function closureProbeHelper(x: number): string {\n"
" const doubled = x + x;\n"
" return String(doubled);\n"
"}\n");
char db[512];
snprintf(db, sizeof(db), "%s/rootpath.db", dir_a);
static const char *project = "rootpath-probe";
cbm_pipeline_t *first = cbm_pipeline_new(dir_a, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(first);
ASSERT_TRUE(cbm_pipeline_set_project_name(first, project));
ASSERT_EQ(cbm_pipeline_run(first), 0);
cbm_pipeline_free(first);
/* Reindex the SAME project name from dir_b: a valid existing generation
* exists, so this routes through the incremental path, not a first-time
* full build. */
cbm_pipeline_t *second = cbm_pipeline_new(dir_b, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(second);
ASSERT_TRUE(cbm_pipeline_set_project_name(second, project));
ASSERT_EQ(cbm_pipeline_run(second), 0);
cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
cbm_pipeline_free(second);
/* The bug is specific to the closure/delta route; confirm we actually
* exercised it rather than a silent fall-back to a full rebuild, which
* would pass even with the fix reverted. */
ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
/* cbm_pipeline_repo_path()/cbm_store_upsert_project() never call
* realpath(): the stored root_path is whatever was passed to
* cbm_pipeline_new(), verbatim, on both the full-rebuild path and this
* one. Compare against dir_b directly, not a canonicalized form, or
* this assertion only holds on a platform where /tmp is not itself a
* symlink (it resolves to /private/tmp on macOS). */
cbm_project_t info = {0};
cbm_store_t *store = cbm_store_open_path(db);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_get_project(store, project, &info), CBM_STORE_OK);
ASSERT_STR_EQ(info.root_path, dir_b);
free((char *)info.name);
free((char *)info.indexed_at);
free((char *)info.root_path);
cbm_store_close(store);
th_rmtree(dir_a);
th_rmtree(dir_b);
PASS();
}
TEST(pipeline_closure_repair_added_name_declines_to_full) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_closure_added_XXXXXX");
@@ -6000,10 +6328,12 @@ static void stem_repo(const char *tmp, int fillers) {
write_temp_file(tmp, "rs/foo.rs", "pub mod bar;\npub fn top() {}\n");
write_temp_file(tmp, "rs/foo/bar.rs", "pub fn inner() {}\n");
write_temp_file(tmp, "c/foo.h", "int foo_add(int a, int b);\n");
write_temp_file(tmp, "c/foo.c", "#include \"foo.h\"\nint foo_add(int a, int b) { return a + b; }\n");
write_temp_file(tmp, "c/foo.c",
"#include \"foo.h\"\nint foo_add(int a, int b) { return a + b; }\n");
write_temp_file(tmp, "c/foo/x.c", "int foo_x(void) { return 1; }\n");
write_temp_file(tmp, "ts/foo.test.ts", "export function stemProbeA(): number { return 1; }\n");
write_temp_file(tmp, "ts/foo/test/a.ts", "export function stemProbeB(): number { return 2; }\n");
write_temp_file(tmp, "ts/foo/test/a.ts",
"export function stemProbeB(): number { return 2; }\n");
write_temp_file(tmp, "go.mod", "module example.com/stem\n\ngo 1.21\n");
write_temp_file(tmp, "goapi/api.sh", "#!/bin/sh\nrun_api() {\n echo api\n}\n");
write_temp_file(tmp, "goapi/api/server.go", "package api\n\nfunc Serve() {}\n");
@@ -6056,7 +6386,8 @@ TEST(pipeline_stem_folder_collision_keeps_both_nodes) {
"C stem pair Module"},
{"SELECT COUNT(*) FROM nodes WHERE label='Folder' AND qualified_name='stem.c.foo/'",
"c/foo/ Folder marked"},
{"SELECT COUNT(*) FROM nodes WHERE label='Module' AND qualified_name='stem.ts.foo.test' AND "
{"SELECT COUNT(*) FROM nodes WHERE label='Module' AND qualified_name='stem.ts.foo.test' "
"AND "
"file_path='ts/foo.test.ts'",
"dotted foo.test.ts Module"},
{"SELECT COUNT(*) FROM nodes WHERE label='Folder' AND qualified_name='stem.ts.foo.test/'",
@@ -6235,8 +6566,9 @@ TEST(incremental_env_join_repair_keeps_unchanged_pairs) {
" value: prod\n");
write_temp_file(tmp, "compose.yaml",
"services:\n web:\n image: acme/web:1\n build: ./web\n");
write_temp_file(tmp, "web/Dockerfile",
"FROM node:20\nCOPY src ./src\nCOPY lib ./lib\nCMD [\"node\", \"src/server.js\"]\n");
write_temp_file(
tmp, "web/Dockerfile",
"FROM node:20\nCOPY src ./src\nCOPY lib ./lib\nCMD [\"node\", \"src/server.js\"]\n");
write_temp_file(tmp, "web/src/server.js",
"const port = process.env.PORT;\nconst mode = process.env.MODE;\n");
write_temp_file(tmp, "web/lib/conf.js", "module.exports = process.env.MODE;\n");
@@ -6246,9 +6578,9 @@ TEST(incremental_env_join_repair_keeps_unchanged_pairs) {
snprintf(db, sizeof(db), "%s/envjoin.db", tmp);
static const char joins[] = "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id "
"WHERE e.type='CONFIGURES' AND s.label='EnvVar'";
#define ENVJOIN_PAIR(key, file) \
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " \
"t.id=e.target_id WHERE e.type='CONFIGURES' AND s.label='EnvVar' AND s.qualified_name LIKE " \
#define ENVJOIN_PAIR(key, file) \
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " \
"t.id=e.target_id WHERE e.type='CONFIGURES' AND s.label='EnvVar' AND s.qualified_name LIKE " \
"'%#env/web/" key "' AND t.file_path='" file "'"
static const char mode_server[] = ENVJOIN_PAIR("MODE", "web/src/server.js");
static const char mode_conf[] = ENVJOIN_PAIR("MODE", "web/lib/conf.js");
@@ -7771,12 +8103,87 @@ TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation
PASS();
}
static void fail_unresolved_capture_before_publication(void *userdata) {
cbm_pipeline_mark_unresolved_capture_failed(userdata);
cbm_pipeline_add_file_error(userdata, "generation.py", "1-1", "parse_partial");
}
TEST(pipeline_capture_failure_preserves_general_coverage_and_rebuilds) {
char tmp[256] = "/tmp/cbm_capture_metadata_XXXXXX";
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
write_temp_file(tmp, "generation.py", "def GeneralCoverage():\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 *first = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
ASSERT_NOT_NULL(first);
cbm_pipeline_incremental_test_before_final_manifest_once(
fail_unresolved_capture_before_publication, first);
ASSERT_EQ(cbm_pipeline_run(first), 0);
char project[256];
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(first));
cbm_pipeline_free(first);
cbm_store_t *store = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(store);
cbm_coverage_meta_t meta = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(store, project, &meta), CBM_STORE_OK);
ASSERT_STR_EQ(meta.recording_status, "complete");
ASSERT_FALSE(meta.unresolved_calls_complete);
cbm_coverage_row_t *rows = NULL;
int row_count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(store, project, "generation.py", &rows, &row_count),
CBM_STORE_OK);
ASSERT_EQ(row_count, 1);
ASSERT_STR_EQ(rows[0].kind, "parse_partial");
cbm_store_free_coverage(rows, row_count);
cbm_store_coverage_meta_clear(&meta);
cbm_store_close(store);
/* A partial repair cannot fill an omitted signal for unchanged files. */
write_temp_file(tmp, "changed.py", "def NewCoverage():\n return 2\n");
cbm_pipeline_incremental_test_reset_faults();
cbm_pipeline_incremental_test_force_legacy_partial_once();
cbm_pipeline_t *partial = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
ASSERT_NOT_NULL(partial);
ASSERT_EQ(cbm_pipeline_run(partial), 0);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL);
cbm_pipeline_free(partial);
store = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(store);
ASSERT_EQ(cbm_store_coverage_meta_get(store, project, &meta), CBM_STORE_OK);
ASSERT_STR_EQ(meta.recording_status, "complete");
ASSERT_FALSE(meta.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&meta);
rows = NULL;
row_count = 0;
ASSERT_EQ(cbm_store_coverage_get_path(store, project, "generation.py", &rows, &row_count),
CBM_STORE_OK);
ASSERT_EQ(row_count, 1);
ASSERT_STR_EQ(rows[0].kind, "parse_partial");
cbm_store_free_coverage(rows, row_count);
cbm_store_close(store);
cbm_pipeline_incremental_test_reset_faults();
cbm_pipeline_t *repair = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
ASSERT_NOT_NULL(repair);
ASSERT_EQ(cbm_pipeline_run(repair), 0);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_FORCED_FULL);
cbm_pipeline_free(repair);
cbm_pipeline_incremental_test_reset_faults();
cbm_pipeline_t *unchanged = cbm_pipeline_new(tmp, db_path, CBM_MODE_FAST);
ASSERT_NOT_NULL(unchanged);
ASSERT_EQ(cbm_pipeline_run(unchanged), 0);
ASSERT_EQ(cbm_pipeline_incremental_test_last_route(), CBM_INCREMENTAL_ROUTE_NOOP);
cbm_pipeline_free(unchanged);
cbm_pipeline_incremental_test_reset_faults();
th_rmtree(tmp);
PASS();
}
/* Metadata participates in exact-input compatibility. Old coverage schema or
* an upgrade to a more comprehensive discovery/index mode must force a
* complete replacement even when every semantic-input byte is unchanged; the
* replacement must write current metadata so the next identical run is a
* true no-op. Cheaper requests retain fuller published coverage separately. */
TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode) {
static int pipeline_exact_inputs_migrate_metadata_and_mode(int legacy_version) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_metadata_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
@@ -7808,7 +8215,7 @@ TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode) {
cbm_coverage_meta_t current_meta = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(metadata_store, project, &current_meta), CBM_STORE_OK);
cbm_coverage_meta_t legacy_meta = current_meta;
legacy_meta.coverage_version = 1;
legacy_meta.coverage_version = legacy_version;
ASSERT_EQ(cbm_store_coverage_replace_ex(metadata_store, project, coverage_rows, coverage_count,
&legacy_meta),
CBM_STORE_OK);
@@ -7875,6 +8282,21 @@ TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode) {
PASS();
}
TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode) {
return pipeline_exact_inputs_migrate_metadata_and_mode(1);
}
/* Published branch version 8 predates language doc links and diagrams.
* Unchanged inputs still need a rebuild, followed by an exact no-op. */
TEST(pipeline_semantic_version_8_index_is_rebuilt_once) {
return pipeline_exact_inputs_migrate_metadata_and_mode(8);
}
/* Published version 10 predates Python lexical-scope call resolution. */
TEST(pipeline_semantic_version_10_index_is_rebuilt_once) {
return pipeline_exact_inputs_migrate_metadata_and_mode(10);
}
typedef struct {
bool published;
int rename_calls;
@@ -11194,9 +11616,9 @@ TEST(pipeline_c_call_reaches_header_function_beside_field) {
/* Both namesakes must exist for the probe to mean anything. */
ASSERT_GTE(fixture_node_count(s, project, "include/linux/mm.h", "put_page", "Function"), 1);
ASSERT_GTE(fixture_node_count(s, project, "arch/arm64/include/asm/kvm_pgtable.h", "put_page",
"Field"),
1);
ASSERT_GTE(
fixture_node_count(s, project, "arch/arm64/include/asm/kvm_pgtable.h", "put_page", "Field"),
1);
ASSERT_EQ(labelled_call_count(s, project, "mte_dump_tag_range", "put_page", "Function"), 1);
ASSERT_EQ(labelled_call_count(s, project, "recv_buffer_free", "put_page", "Function"), 1);
/* A bare C call never names the struct member. */
@@ -11230,9 +11652,9 @@ TEST(pipeline_c_call_reaches_header_function_beside_field_parallel) {
ASSERT_NOT_NULL(s);
ASSERT_GTE(fixture_node_count(s, project, "include/linux/mm.h", "put_page", "Function"), 1);
ASSERT_GTE(fixture_node_count(s, project, "arch/arm64/include/asm/kvm_pgtable.h", "put_page",
"Field"),
1);
ASSERT_GTE(
fixture_node_count(s, project, "arch/arm64/include/asm/kvm_pgtable.h", "put_page", "Field"),
1);
ASSERT_EQ(labelled_call_count(s, project, "mte_dump_tag_range", "put_page", "Function"), 1);
ASSERT_EQ(labelled_call_count(s, project, "recv_buffer_free", "put_page", "Function"), 1);
ASSERT_EQ(labelled_call_count(s, project, "mte_dump_tag_range", "put_page", "Field"), 0);
@@ -15935,8 +16357,8 @@ static int python_init_scope_pipeline_case(bool parallel, const char *failure_st
: body);
}
if (written && cross_walk_stage) {
written = python_module_write_fixture_file(root, "memo_def.py",
"class S:\n def m(self):\n return 1\n");
written = python_module_write_fixture_file(
root, "memo_def.py", "class S:\n def m(self):\n return 1\n");
}
if (written && !failure_stage) {
written = python_module_write_fixture_file(root, "plain.py", body) &&
@@ -16001,8 +16423,10 @@ static int python_init_scope_pipeline_case(bool parallel, const char *failure_st
correct = project &&
cbm_store_find_nodes_by_label(store, project, "File", &files, &file_count) ==
CBM_STORE_OK &&
file_count ==
(cross_walk_stage ? 2 : failure_stage ? 1 : 5) + (parallel ? 52 : 0);
file_count == (cross_walk_stage ? 2
: failure_stage ? 1
: 5) +
(parallel ? 52 : 0);
cbm_store_free_nodes(files, file_count);
if (project && !failure_stage) {
const char *modules[] = {"plain", "pkg"};
@@ -18016,14 +18440,13 @@ static int pyn_assert_shadowing(cbm_store_t *s, const char *project) {
snprintf(local_def, sizeof(local_def), "%s.uses_local.helper", files[f]);
snprintf(module_caller, sizeof(module_caller), "%s.uses_module", files[f]);
snprintf(module_def, sizeof(module_def), "%s.helper", files[f]);
ASSERT_TRUE(
pyn_edge_with(s, project, local_caller, local_def, "CALLS", local_strategy[f]));
ASSERT_TRUE(pyn_edge_with(s, project, local_caller, local_def, "CALLS", local_strategy[f]));
ASSERT_FALSE(pyn_edge(s, project, local_caller, module_def, "CALLS"));
ASSERT_TRUE(pyn_edge(s, project, module_caller, module_def, "CALLS"));
ASSERT_FALSE(pyn_edge(s, project, module_caller, local_def, "CALLS"));
}
ASSERT_TRUE(pyn_edge_with(s, project, "sa.uses_decorated", "sa.uses_decorated.helper",
"CALLS", "\"strategy\":\"lexical_scope\""));
ASSERT_TRUE(pyn_edge_with(s, project, "sa.uses_decorated", "sa.uses_decorated.helper", "CALLS",
"\"strategy\":\"lexical_scope\""));
ASSERT_FALSE(pyn_edge(s, project, "sa.uses_decorated", "sa.helper", "CALLS"));
return 0;
}
@@ -18336,9 +18759,8 @@ static int pyn_run_init_collision(int fillers) {
const char *project = cbm_pipeline_project_name(p);
imports[run] = pyn_edge(s, project, "tests.test_app.py.__file__",
"src.rowpkg.layout.Layout", "IMPORTS");
calls[run] =
pyn_edge(s, project, "tests.test_app.test_build", "src.rowpkg.layout.Layout",
"CALLS");
calls[run] = pyn_edge(s, project, "tests.test_app.test_build",
"src.rowpkg.layout.Layout", "CALLS");
method[run] = pyn_edge(s, project, "tests.test_app.test_build",
"src.rowpkg.layout.Layout.create", "CALLS");
cbm_store_close(s);
@@ -19345,13 +19767,11 @@ TEST(pipeline_cpp_overloads_are_listed_as_variants_c1) {
PASS();
}
/* CBM_SEMANTIC_INDEX_VERSION 4: the C-family node identities changed (macro QNs end
* in "#macro", unscoped enumerators are flat, typedef names are nodes). An index
* written at version 3 holds the old QNs for every file that did not change, and an
* unchanged repository is otherwise a no-op, so the version is what makes the new
* binary rebuild it. The stored index is put back to the version-3 state by hand
* (metadata and the old macro QN); the run after that must replace it in full. */
TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) {
/* The C-family node identities changed (macro QNs end in "#macro", unscoped
* enumerators are flat, typedef names are nodes). Main's version 3 and this
* branch's version 5 both hold the old QNs for unchanged files. Restore that
* legacy metadata and macro QN by hand; an unchanged repository must rebuild. */
static int pipeline_legacy_c_identity_index_is_rebuilt(int legacy_version) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_c1_version_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
@@ -19367,7 +19787,7 @@ TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) {
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(first));
cbm_pipeline_free(first);
/* Put the published index back to what version 3 wrote. */
/* Put the published index back to what the legacy version wrote. */
cbm_store_t *store = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(store);
char label[64];
@@ -19380,7 +19800,7 @@ TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) {
cbm_coverage_meta_t meta = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(store, project, &meta), CBM_STORE_OK);
cbm_coverage_meta_t old_meta = meta;
old_meta.coverage_version = 3;
old_meta.coverage_version = legacy_version;
ASSERT_EQ(
cbm_store_coverage_replace_ex(store, project, coverage_rows, coverage_count, &old_meta),
CBM_STORE_OK);
@@ -19423,10 +19843,18 @@ TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) {
ASSERT_EQ(stored_version, CBM_SEMANTIC_INDEX_VERSION);
ASSERT_TRUE(fenced_after);
ASSERT_FALSE(plain_after);
ASSERT_GTE(CBM_SEMANTIC_INDEX_VERSION, 4);
ASSERT_GT(CBM_SEMANTIC_INDEX_VERSION, legacy_version);
PASS();
}
TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1) {
return pipeline_legacy_c_identity_index_is_rebuilt(3);
}
TEST(pipeline_semantic_version_5_index_is_rebuilt_in_full_c1) {
return pipeline_legacy_c_identity_index_is_rebuilt(5);
}
/* `#include <linux/device.h>` from arch/x/bugs.c: two headers end with the
* include path (include/linux/device.h, tools/virtio/linux/device.h) and each
* declares `struct device`. The exact-file lookup returned the first matching
@@ -28185,6 +28613,11 @@ SUITE(pipeline) {
RUN_TEST(pipeline_committed_counts_match_persisted);
RUN_TEST(pipeline_adr_survives_full_reindex);
RUN_TEST(pipeline_export_error_snapshot_on_artifact_failure);
RUN_TEST(pipeline_records_unresolved_injected_call_sites);
RUN_TEST(pipeline_unresolved_review_sequential);
RUN_TEST(pipeline_unresolved_capture_bounds_count_and_json_bytes);
RUN_TEST(pipeline_unresolved_review_parallel);
RUN_TEST(pipeline_unresolved_review_spill);
RUN_TEST(pipeline_structure_edges);
RUN_TEST(pipeline_branch_root_structure);
RUN_TEST(pipeline_project_name_derived);
@@ -28329,6 +28762,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_c_variants_on_sequential_and_parallel_paths_c1);
RUN_TEST(pipeline_cpp_overloads_are_listed_as_variants_c1);
RUN_TEST(pipeline_semantic_version_3_index_is_rebuilt_in_full_c1);
RUN_TEST(pipeline_semantic_version_5_index_is_rebuilt_in_full_c1);
RUN_TEST(pipeline_python_app_factory_routes_issue1548);
RUN_TEST(pipeline_python_nested_def_is_function_local_issue1548);
RUN_TEST(pipeline_python_local_classes_stay_in_their_def_issue1548);
@@ -28651,6 +29085,7 @@ SUITE(pipeline_semantic_manifest_repro) {
RUN_TEST(pipeline_dbt_jinja_lineage);
RUN_TEST(pipeline_parallel_manifest_is_byte_stable_above_threshold);
RUN_TEST(pipeline_closure_repair_body_edit_converges_with_fresh_full);
RUN_TEST(pipeline_incremental_root_path_refreshed_across_directories);
RUN_TEST(pipeline_closure_repair_removed_def_drops_dependent_edge);
RUN_TEST(pipeline_closure_repair_added_name_declines_to_full);
RUN_TEST(pipeline_closure_repair_new_file_declines_to_full);
@@ -28676,6 +29111,9 @@ SUITE(pipeline_semantic_manifest_repro) {
RUN_TEST(pipeline_source_addition_before_publication_preserves_previous_generation);
RUN_TEST(pipeline_tsconfig_mutation_before_publication_preserves_previous_generation);
RUN_TEST(pipeline_exact_inputs_migrate_coverage_metadata_and_index_mode);
RUN_TEST(pipeline_semantic_version_8_index_is_rebuilt_once);
RUN_TEST(pipeline_semantic_version_10_index_is_rebuilt_once);
RUN_TEST(pipeline_capture_failure_preserves_general_coverage_and_rebuilds);
RUN_TEST(pipeline_existing_artifact_refreshes_after_default_forced_full_reindex);
RUN_TEST(pipeline_full_cancel_after_predump_preserves_previous_generation);
RUN_TEST(pipeline_full_cancel_after_destination_prepare_preserves_previous_generation);
+256
View File
@@ -167,9 +167,265 @@ TEST(bulk_crash_recovery) {
}
#endif /* _WIN32 */
TEST(coverage_meta_has_independent_unresolved_completeness) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
sqlite3_stmt *stmt = NULL;
ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(s),
"SELECT unresolved_calls_complete FROM index_coverage_meta;", -1,
&stmt, NULL),
SQLITE_OK);
sqlite3_finalize(stmt);
cbm_store_close(s);
PASS();
}
TEST(coverage_meta_legacy_reader_preserves_general_metadata) {
char path[256];
make_temp_path(path, sizeof(path));
cleanup_db(path);
cbm_store_t *writer = cbm_store_open_path(path);
ASSERT_NOT_NULL(writer);
ASSERT_EQ(cbm_store_upsert_project(writer, "legacy-coverage", "/tmp/legacy-coverage"),
CBM_STORE_OK);
ASSERT_EQ(
sqlite3_exec(cbm_store_get_db(writer),
"DROP TABLE index_coverage_meta;"
"CREATE TABLE index_coverage_meta(project TEXT PRIMARY KEY,generation TEXT,"
"index_mode TEXT,recorded_at TEXT,recording_status TEXT,"
"ignored_files_stored INTEGER,ignored_files_total INTEGER,"
"coverage_version INTEGER,hash_records_complete INTEGER);"
"INSERT INTO index_coverage_meta VALUES('legacy-coverage','generation','full',"
"'recorded','truncated',1,2,5,1);",
NULL, NULL, NULL),
SQLITE_OK);
cbm_store_close(writer);
cbm_store_t *reader = cbm_store_open_path_query(path);
ASSERT_NOT_NULL(reader);
cbm_coverage_meta_t meta = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(reader, "legacy-coverage", &meta), CBM_STORE_OK);
ASSERT_STR_EQ(meta.recording_status, "truncated");
ASSERT_EQ(meta.ignored_files_stored, 1);
ASSERT_EQ(meta.ignored_files_total, 2);
ASSERT_EQ(meta.coverage_version, 5);
ASSERT_TRUE(meta.hash_records_complete);
ASSERT_FALSE(meta.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&meta);
cbm_store_close(reader);
writer = cbm_store_open_path(path);
ASSERT_NOT_NULL(writer);
sqlite3_stmt *stmt = NULL;
ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(writer),
"SELECT unresolved_calls_complete FROM index_coverage_meta;", -1,
&stmt, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
ASSERT_EQ(sqlite3_column_int(stmt, 0), 0);
sqlite3_finalize(stmt);
ASSERT_EQ(cbm_store_coverage_meta_get(writer, "legacy-coverage", &meta), CBM_STORE_OK);
ASSERT_STR_EQ(meta.recording_status, "truncated");
ASSERT_FALSE(meta.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&meta);
cbm_store_close(writer);
cleanup_db(path);
PASS();
}
TEST(coverage_meta_completeness_round_trip_and_failed_replace) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *project = "capture-meta";
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/capture-meta"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "gap.js", "fixture", 0, 0), CBM_STORE_OK);
cbm_coverage_row_t row = {.rel_path = "gap.js", .kind = "parse_partial", .detail = "2-3"};
cbm_coverage_meta_t meta = {.generation = "baseline",
.index_mode = "full",
.recording_status = "complete",
.coverage_version = 5,
.hash_records_complete = true,
.unresolved_calls_complete = true};
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK);
cbm_coverage_meta_t fetched = {0};
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK);
ASSERT_TRUE(fetched.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&fetched);
ASSERT_EQ(
sqlite3_exec(cbm_store_get_db(s),
"CREATE TRIGGER reject_capture_meta BEFORE UPDATE ON index_coverage_meta "
"BEGIN SELECT RAISE(ABORT, 'metadata fault'); END;",
NULL, NULL, NULL),
SQLITE_OK);
meta.generation = "failed";
meta.unresolved_calls_complete = false;
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, NULL, 0, &meta), CBM_STORE_ERR);
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK);
ASSERT_STR_EQ(fetched.generation, "baseline");
ASSERT_TRUE(fetched.unresolved_calls_complete);
cbm_store_coverage_meta_clear(&fetched);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get(s, project, &rows, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(rows[0].kind, "parse_partial");
cbm_store_free_coverage(rows, count);
ASSERT_EQ(
sqlite3_exec(cbm_store_get_db(s), "DROP TRIGGER reject_capture_meta;", NULL, NULL, NULL),
SQLITE_OK);
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, &meta), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_OK);
ASSERT_FALSE(fetched.unresolved_calls_complete);
ASSERT_STR_EQ(fetched.recording_status, "complete");
cbm_store_coverage_meta_clear(&fetched);
ASSERT_EQ(cbm_store_coverage_replace_ex(s, project, &row, 1, NULL), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_meta_get(s, project, &fetched), CBM_STORE_NOT_FOUND);
cbm_store_close(s);
PASS();
}
typedef struct {
int statements;
int fullscan_steps;
int vm_steps;
} unresolved_query_cost_t;
static int record_unresolved_query_cost(unsigned type, void *userdata, void *statement,
void *elapsed) {
(void)elapsed;
unresolved_query_cost_t *cost = userdata;
sqlite3_stmt *stmt = statement;
const char *sql = sqlite3_sql(stmt);
if (type == SQLITE_TRACE_PROFILE && sql &&
(strstr(sql, "FROM index_unresolved_candidates") ||
strstr(sql, "AND kind = 'unresolved_calls'"))) {
cost->statements++;
cost->fullscan_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_FULLSCAN_STEP, 0);
cost->vm_steps += sqlite3_stmt_status(stmt, SQLITE_STMTSTATUS_VM_STEP, 0);
}
return 0;
}
TEST(unresolved_coverage_queries_use_file_and_candidate_indexes) {
enum { UNRELATED_FILES = 2000 };
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *project = "bounded-lookups";
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/bounded-lookups"), CBM_STORE_OK);
sqlite3 *db = cbm_store_get_db(s);
ASSERT_EQ(sqlite3_exec(db, "BEGIN;", NULL, NULL, NULL), SQLITE_OK);
sqlite3_stmt *coverage = NULL;
sqlite3_stmt *candidate = NULL;
ASSERT_EQ(sqlite3_prepare_v2(db,
"INSERT INTO index_coverage(project,rel_path,kind,detail) "
"VALUES(?1,?2,'unresolved_calls',?3);",
-1, &coverage, NULL),
SQLITE_OK);
ASSERT_EQ(
sqlite3_prepare_v2(db,
"INSERT INTO index_unresolved_candidates(project,candidate,rel_path) "
"VALUES(?1,?2,?3);",
-1, &candidate, NULL),
SQLITE_OK);
for (int i = 0; i <= UNRELATED_FILES; i++) {
char path[64];
snprintf(path, sizeof(path), "file-%04d.js", i);
ASSERT_EQ(sqlite3_bind_text(coverage, 1, project, -1, SQLITE_STATIC), SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(coverage, 2, path, -1, SQLITE_TRANSIENT), SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(coverage, 3,
i == 0 ? "[{\"candidate\":\"target.fn\"}]"
: "[{\"candidate\":\"other.fn\"}]",
-1, SQLITE_STATIC),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(coverage), SQLITE_DONE);
ASSERT_EQ(sqlite3_reset(coverage), SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(candidate, 1, project, -1, SQLITE_STATIC), SQLITE_OK);
ASSERT_EQ(
sqlite3_bind_text(candidate, 2, i == 0 ? "target.fn" : "other.fn", -1, SQLITE_STATIC),
SQLITE_OK);
ASSERT_EQ(sqlite3_bind_text(candidate, 3, path, -1, SQLITE_TRANSIENT), SQLITE_OK);
ASSERT_EQ(sqlite3_step(candidate), SQLITE_DONE);
ASSERT_EQ(sqlite3_reset(candidate), SQLITE_OK);
}
sqlite3_finalize(coverage);
sqlite3_finalize(candidate);
ASSERT_EQ(sqlite3_exec(db, "COMMIT;", NULL, NULL, NULL), SQLITE_OK);
unresolved_query_cost_t cost = {0};
ASSERT_EQ(sqlite3_trace_v2(db, SQLITE_TRACE_PROFILE, record_unresolved_query_cost, &cost),
SQLITE_OK);
cbm_coverage_row_t *rows = NULL;
int count = 0;
ASSERT_EQ(cbm_store_coverage_get_unresolved_path(s, project, "file-0000.js", &rows, &count),
CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_STR_EQ(rows[0].rel_path, "file-0000.js");
cbm_store_free_coverage(rows, count);
bool found = false;
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "target.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
ASSERT_EQ(sqlite3_trace_v2(db, 0, NULL, NULL), SQLITE_OK);
ASSERT_EQ(cost.statements, 3);
ASSERT_EQ(cost.fullscan_steps, 0);
ASSERT_TRUE(cost.vm_steps < 300);
cbm_store_close(s);
PASS();
}
TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *project = "candidate-lifecycle";
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/candidate-lifecycle"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK);
cbm_coverage_row_t row = {.rel_path = "calls.js",
.kind = "unresolved_calls",
.detail = "[{\"candidate\":\"old.fn\"}]"};
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
bool found = false;
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
row.detail = "[{\"candidate\":\"new.fn\"}]";
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "old.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "new.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
const char *markers[] = {"[{\"truncated\":true}]", "invalid-json", "{}", "[1]"};
for (size_t i = 0; i < sizeof(markers) / sizeof(markers[0]); i++) {
row.detail = markers[i];
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_TRUE(found);
}
ASSERT_EQ(cbm_store_delete_file_hash(s, project, "calls.js"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
ASSERT_EQ(cbm_store_upsert_file_hash(s, project, "calls.js", "fixture", 0, 0), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_replace(s, project, &row, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_delete_project(s, project), CBM_STORE_OK);
ASSERT_EQ(cbm_store_coverage_has_unresolved_candidate(s, project, "absent.fn", &found),
CBM_STORE_OK);
ASSERT_FALSE(found);
cbm_store_close(s);
PASS();
}
/* ── Suite ──────────────────────────────────────────────────────── */
SUITE(store_bulk) {
RUN_TEST(coverage_meta_has_independent_unresolved_completeness);
RUN_TEST(coverage_meta_legacy_reader_preserves_general_metadata);
RUN_TEST(coverage_meta_completeness_round_trip_and_failed_replace);
RUN_TEST(unresolved_coverage_queries_use_file_and_candidate_indexes);
RUN_TEST(unresolved_candidate_index_tracks_replacement_pruning_and_markers);
RUN_TEST(bulk_pragma_wal_invariant);
RUN_TEST(bulk_pragma_end_wal_invariant);
#ifndef _WIN32
+95 -3
View File
@@ -38,7 +38,11 @@ static const char *const gd_inserts[] = {
"INSERT INTO index_coverage(project,rel_path,kind,detail) VALUES(CAST(X'70' AS TEXT),CAST(X'62' AS TEXT),CAST(X'736b6970' AS TEXT),CAST(X'' AS TEXT));",
"INSERT INTO index_coverage_meta(project,generation,index_mode,recorded_at,recording_status,ignored_files_stored,ignored_files_total,coverage_version,hash_records_complete) VALUES(CAST(X'70' AS TEXT),CAST(X'67656e' AS TEXT),CAST(X'66756c6c' AS TEXT),CAST(X'74' AS TEXT),CAST(X'636f6d706c657465' AS TEXT),-1,0,1,1);",
};
static const unsigned char gd_golden_sha[32] = { 0x46, 0x44, 0x71, 0x72, 0xdc, 0x9b, 0x2d, 0xa8, 0xda, 0xc8, 0x54, 0x5a, 0x56, 0x88, 0xab, 0x59, 0x1d, 0xad, 0x39, 0xe2, 0x9b, 0xe6, 0x33, 0x9c, 0x3d, 0x26, 0x49, 0x27, 0xd0, 0x94, 0xe9, 0x3e };
/* Stream version 2: the oracle's version-1 stream (4038 framed bytes, golden
* 46447172dc9b2da8...) with only its version word set to 2. This fixture has
* no index_unresolved_candidates table and no unresolved_calls_complete
* column, so nothing else in its stream changes. */
static const unsigned char gd_golden_sha[32] = { 0xd0, 0x51, 0xc4, 0x56, 0x11, 0x5c, 0xf7, 0x3c, 0x75, 0xe2, 0x89, 0xb5, 0xc3, 0x92, 0xa2, 0x56, 0xa5, 0x66, 0xe6, 0x62, 0xfd, 0x56, 0x66, 0x35, 0x05, 0xc6, 0x1e, 0xa5, 0x49, 0x7d, 0xa1, 0x30 };
#define GD_GOLDEN_ROWS UINT64_C(17)
#define GD_GOLDEN_BYTES UINT64_C(4038)
@@ -55,9 +59,11 @@ static bool gd_int(sqlite3 *db, const char *sql, int64_t want) {
return ok;
}
static bool gd_seed(sqlite3 *db, bool reverse, bool meta) {
static const char drop[] = "PRAGMA foreign_keys=OFF;"
static const char drop[] =
"PRAGMA foreign_keys=OFF;"
"DROP TABLE IF EXISTS edges;DROP TABLE IF EXISTS nodes;DROP TABLE IF EXISTS file_hashes;"
"DROP TABLE IF EXISTS project_summaries;DROP TABLE IF EXISTS lsp_surface;"
"DROP TABLE IF EXISTS index_unresolved_candidates;"
"DROP TABLE IF EXISTS index_coverage;DROP TABLE IF EXISTS index_coverage_meta;"
"DROP TABLE IF EXISTS projects;DROP TABLE IF EXISTS store_meta;";
if (!db || gd_sql(db,drop)!=SQLITE_OK) return false;
@@ -119,13 +125,76 @@ static bool gd_cancel(void *opaque) {
return c->stop || (c->armed && c->trip && c->calls>=c->trip);
}
TEST(store_graph_digest_binds_unresolved_candidate_index) {
cbm_store_t *s = cbm_store_open_memory();
cbm_coverage_row_t row = {.rel_path = "calls.js",
.kind = "unresolved_calls",
.detail = "[{\"candidate\":\"p.target\"}]"};
bool setup = s && cbm_store_upsert_project(s, "p", "/tmp/digest-candidates") == CBM_STORE_OK &&
cbm_store_upsert_file_hash(s, "p", "calls.js", "fixture", 0, 0) == CBM_STORE_OK &&
cbm_store_coverage_replace(s, "p", &row, 1) == CBM_STORE_OK;
bool candidate_before = false, candidate_after = true;
cbm_store_graph_digest_t before = {0}, after = {0};
unsigned char content_before[CBM_STORE_GRAPH_DIGEST_BYTES] = {0},
content_after[CBM_STORE_GRAPH_DIGEST_BYTES] = {0};
bool read_before =
setup &&
cbm_store_coverage_has_unresolved_candidate(s, "p", "p.target", &candidate_before) ==
CBM_STORE_OK &&
gd_p(s, &before) && cbm_store_graph_content_digest(s, "p", content_before) == CBM_STORE_OK;
/* Deliberately bypass publication/generation advancement: inbound reads this table. */
bool removed = read_before &&
gd_sql(cbm_store_get_db(s),
"DELETE FROM index_unresolved_candidates WHERE project='p'") == SQLITE_OK;
bool read_after =
removed &&
cbm_store_coverage_has_unresolved_candidate(s, "p", "p.target", &candidate_after) ==
CBM_STORE_OK &&
gd_p(s, &after) && cbm_store_graph_content_digest(s, "p", content_after) == CBM_STORE_OK;
cbm_store_close(s);
ASSERT_TRUE(setup);
ASSERT_TRUE(read_before);
ASSERT_TRUE(candidate_before);
ASSERT_TRUE(removed);
ASSERT_TRUE(read_after);
ASSERT_FALSE(candidate_after);
ASSERT_TRUE(gd_different(&before, &after));
ASSERT_TRUE(memcmp(content_before, content_after, sizeof(content_before)) != 0);
PASS();
}
TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence) {
cbm_store_t *s = gd_fixture(false, false);
unsigned char legacy[CBM_STORE_GRAPH_DIGEST_BYTES] = {0},
again[CBM_STORE_GRAPH_DIGEST_BYTES] = {0};
bool old = s && cbm_store_graph_content_digest(s, "p", legacy) == CBM_STORE_OK;
bool malformed =
old && gd_sql(cbm_store_get_db(s),
"CREATE TABLE index_unresolved_candidates(project TEXT,unexpected TEXT)") ==
SQLITE_OK;
int content_rc = malformed ? cbm_store_graph_content_digest(s, "p", again) : CBM_STORE_OK;
bool graph_rejected = malformed && gd_reject(s, CBM_STORE_GRAPH_DIGEST_SCHEMA);
bool absent =
graph_rejected &&
gd_sql(cbm_store_get_db(s), "DROP TABLE index_unresolved_candidates") == SQLITE_OK &&
cbm_store_graph_content_digest(s, "p", again) == CBM_STORE_OK;
cbm_store_close(s);
ASSERT_TRUE(old);
ASSERT_TRUE(malformed);
ASSERT_EQ(content_rc, CBM_STORE_ERR);
ASSERT_TRUE(graph_rejected);
ASSERT_TRUE(absent);
ASSERT_TRUE(memcmp(legacy, again, sizeof(legacy)) == 0);
PASS();
}
TEST(store_graph_digest_independent_canonical_golden) {
cbm_store_t *s=gd_fixture(false,false); cbm_store_graph_digest_t out={0};
bool setup=s && gd_int(cbm_store_get_db(s),"SELECT count(*) FROM nodes",2) &&
gd_int(cbm_store_get_db(s),"SELECT length(CAST(properties AS BLOB)) FROM nodes WHERE id=-7",4) &&
gd_int(cbm_store_get_db(s),"SELECT count(*) FROM lsp_surface WHERE typeof(ref_bloom)='blob'",2);
bool got=setup && gd_p(s,&out); cbm_store_close(s);
ASSERT_TRUE(setup); ASSERT_TRUE(got); ASSERT_EQ(out.version,1);
ASSERT_TRUE(setup); ASSERT_TRUE(got); ASSERT_EQ(out.version,2);
ASSERT_TRUE(out.rows==GD_GOLDEN_ROWS); ASSERT_TRUE(out.framed_bytes==GD_GOLDEN_BYTES);
ASSERT_TRUE(memcmp(out.sha256,gd_golden_sha,32)==0); PASS();
}
@@ -303,6 +372,26 @@ TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility) {
ASSERT_TRUE(seeded);ASSERT_TRUE(compatible);PASS();
}
/* Legacy streams stay stable, while the new independent capture signal must
* be bound: changing only that value changes the digest. */
TEST(store_graph_digest_binds_unresolved_capture_completeness) {
cbm_store_t *store = gd_fixture(false, false);
ASSERT_NOT_NULL(store);
cbm_store_graph_digest_t incomplete = {0}, complete = {0};
ASSERT_EQ(gd_sql(cbm_store_get_db(store),
"ALTER TABLE index_coverage_meta ADD COLUMN "
"unresolved_calls_complete INTEGER NOT NULL DEFAULT 0;"),
SQLITE_OK);
ASSERT_TRUE(gd_p(store, &incomplete));
ASSERT_EQ(gd_sql(cbm_store_get_db(store),
"UPDATE index_coverage_meta SET unresolved_calls_complete=1;"),
SQLITE_OK);
ASSERT_TRUE(gd_p(store, &complete));
cbm_store_close(store);
ASSERT_TRUE(gd_different(&incomplete, &complete));
PASS();
}
TEST(store_graph_digest_schema_and_runtime_types_fail_closed) {
static const char *const bad[] = {
"DROP TABLE file_hashes", "DROP TABLE project_summaries", "DROP TABLE lsp_surface",
@@ -595,11 +684,14 @@ TEST(store_graph_digest_concurrent_isolation_with_held_callback_gates) {
}
SUITE(store_graph_digest) {
RUN_TEST(store_graph_digest_binds_unresolved_candidate_index);
RUN_TEST(store_graph_digest_candidate_index_errors_are_not_legacy_absence);
RUN_TEST(store_graph_digest_independent_canonical_golden);
RUN_TEST(store_graph_digest_logical_order_and_excluded_storage_are_stable);
RUN_TEST(store_graph_digest_every_known_value_and_cross_project_edge_is_bound);
RUN_TEST(store_graph_digest_raw_types_project_bytes_and_schema_sql_are_distinct);
RUN_TEST(store_graph_digest_legacy_generation_and_live_schema_compatibility);
RUN_TEST(store_graph_digest_binds_unresolved_capture_completeness);
RUN_TEST(store_graph_digest_schema_and_runtime_types_fail_closed);
RUN_TEST(store_graph_digest_wal_pins_old_content_then_observes_next_scope);
RUN_TEST(store_graph_digest_exact_budgets_zero_output_and_terminal_scope);
+104
View File
@@ -2603,6 +2603,109 @@ TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails) {
PASS();
}
/* Copy store_meta's shadow fingerprint for `project` into `out` ("" if absent). */
static void coverage_shadow_fp(cbm_store_t *s, const char *project, char *out, size_t cap) {
out[0] = '\0';
char key[256];
snprintf(key, sizeof(key), "coverage_shadow_fp:%s", project);
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(cbm_store_get_db(s), "SELECT v FROM store_meta WHERE k = ?1;", -1, &stmt,
NULL) != SQLITE_OK) {
return;
}
sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT);
if (sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_text(stmt, 0)) {
snprintf(out, cap, "%s", (const char *)sqlite3_column_text(stmt, 0));
}
sqlite3_finalize(stmt);
}
/* #2265: unresolved_calls rows describe files that WERE indexed, so they stay
* out of the "<project>::missed" graph like by-design not_indexed_* rows:
* (a) a project whose only rows are unresolved_calls gets no shadow graph,
* (b) a file with parse_partial AND unresolved_calls keeps its parse_partial
* File node (the later unresolved_calls row used to replace it), and
* (c) the shadow fingerprint ignores unresolved_calls rows, so the skip gate
* and store_meta match a project without them. */
TEST(store_coverage_shadow_excludes_unresolved_calls) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
const char *only = "cov-unres-only";
const char *mixed = "cov-unres-mixed";
const char *plain = "cov-unres-plain";
ASSERT_EQ(cbm_store_upsert_project(s, only, "/tmp/cov-unres-only"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_project(s, mixed, "/tmp/cov-unres-mixed"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_project(s, plain, "/tmp/cov-unres-plain"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, only, "src/a.go", "", 1, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, only, "src/b.go", "", 2, 2), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, mixed, "src/both.go", "", 3, 3), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, mixed, "lib/calls.go", "", 4, 4), CBM_STORE_OK);
ASSERT_EQ(cbm_store_upsert_file_hash(s, plain, "src/both.go", "", 3, 3), CBM_STORE_OK);
/* (a) Only unresolved_calls (plus a by-design row): no shadow project. */
cbm_coverage_row_t only_rows[] = {
{.rel_path = "src/a.go", .kind = "unresolved_calls", .detail = "[{\"candidate\":\"a.f\"}]"},
{.rel_path = "src/b.go", .kind = "unresolved_calls", .detail = "[{\"candidate\":\"b.g\"}]"},
{.rel_path = "vendor", .kind = "not_indexed_dir", .detail = "excluded subtree"},
};
ASSERT_EQ(cbm_store_coverage_replace(s, only, only_rows, 3), CBM_STORE_OK);
cbm_coverage_row_t *got = NULL;
int n = 0;
ASSERT_EQ(cbm_store_coverage_get(s, only, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 3); /* the table keeps the evidence; only the view skips it */
cbm_store_free_coverage(got, n);
ASSERT_EQ(cbm_store_count_nodes(s, "cov-unres-only::missed"), 0);
cbm_project_t shadow = {0};
ASSERT_EQ(cbm_store_get_project(s, "cov-unres-only::missed", &shadow), CBM_STORE_NOT_FOUND);
cbm_project_free_fields(&shadow);
/* (b) parse_partial + unresolved_calls on one path, and a second file
* with unresolved_calls only: the shadow holds exactly the parse_partial
* File node under src/. */
cbm_coverage_row_t mixed_rows[] = {
{.rel_path = "src/both.go", .kind = "parse_partial", .detail = "3-4"},
{.rel_path = "src/both.go",
.kind = "unresolved_calls",
.detail = "[{\"candidate\":\"both.h\"}]"},
{.rel_path = "lib/calls.go",
.kind = "unresolved_calls",
.detail = "[{\"candidate\":\"calls.k\"}]"},
};
ASSERT_EQ(cbm_store_coverage_replace(s, mixed, mixed_rows, 3), CBM_STORE_OK);
cbm_node_t *nodes = NULL;
int nc = 0;
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "cov-unres-mixed::missed", "File", &nodes, &nc),
CBM_STORE_OK);
ASSERT_EQ(nc, 1);
ASSERT_STR_EQ(nodes[0].file_path, "src/both.go");
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"kind\":\"parse_partial\""));
ASSERT_NOT_NULL(strstr(nodes[0].properties_json, "\"detail\":\"3-4\""));
ASSERT_NULL(strstr(nodes[0].properties_json, "unresolved_calls"));
cbm_store_free_nodes(nodes, nc);
nodes = NULL;
nc = 0;
ASSERT_EQ(cbm_store_find_nodes_by_label(s, "cov-unres-mixed::missed", "Folder", &nodes, &nc),
CBM_STORE_OK);
ASSERT_EQ(nc, 1);
ASSERT_STR_EQ(nodes[0].name, "src");
cbm_store_free_nodes(nodes, nc);
/* (c) Same failure rows without the unresolved_calls evidence: the stored
* fingerprints match. */
cbm_coverage_row_t plain_row = {
.rel_path = "src/both.go", .kind = "parse_partial", .detail = "3-4"};
ASSERT_EQ(cbm_store_coverage_replace(s, plain, &plain_row, 1), CBM_STORE_OK);
char fp_mixed[128];
char fp_plain[128];
coverage_shadow_fp(s, mixed, fp_mixed, sizeof(fp_mixed));
coverage_shadow_fp(s, plain, fp_plain, sizeof(fp_plain));
ASSERT_TRUE(fp_plain[0] != '\0');
ASSERT_STR_EQ(fp_mixed, fp_plain);
cbm_store_close(s);
PASS();
}
SUITE(store_nodes) {
RUN_TEST(store_qn_suffix_is_literal);
RUN_TEST(store_qn_suffix_is_unbounded);
@@ -2612,6 +2715,7 @@ SUITE(store_nodes) {
RUN_TEST(store_coverage_meta_zero_row_truncation_and_delete);
RUN_TEST(store_coverage_replace_rejects_invalid_row_arguments);
RUN_TEST(store_coverage_replace_rolls_back_when_shadow_rebuild_fails);
RUN_TEST(store_coverage_shadow_excludes_unresolved_calls);
RUN_TEST(sql_label_allowlists_match_cbm_label_is_type_like);
RUN_TEST(sql_relation_labels_match_cbm_label_is_relation);
RUN_TEST(store_open_memory);