mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge branch 'main' into fix/2386-bm25-post-window-filters
This commit is contained in:
36 files changed
+3827
-509
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ───────────────────────────────── */
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+305
-38
@@ -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"
|
||||
@@ -6849,11 +6850,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;
|
||||
@@ -6883,6 +6886,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);
|
||||
@@ -6940,6 +6948,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);
|
||||
@@ -6977,7 +6991,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, "
|
||||
@@ -6985,7 +6999,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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7197,6 +7213,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,
|
||||
@@ -7211,26 +7264,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7250,6 +7291,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. */
|
||||
@@ -7333,6 +7377,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);
|
||||
@@ -9904,6 +9950,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);
|
||||
@@ -10240,6 +10487,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;
|
||||
@@ -10328,7 +10596,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);
|
||||
@@ -10339,7 +10607,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);
|
||||
@@ -10405,8 +10673,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,
|
||||
@@ -10415,8 +10682,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,
|
||||
@@ -10474,13 +10740,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) {
|
||||
@@ -10505,12 +10769,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) {
|
||||
@@ -11026,11 +11289,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
|
||||
@@ -11040,17 +11304,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);
|
||||
}
|
||||
@@ -11061,7 +11328,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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -3753,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,
|
||||
@@ -3862,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. */
|
||||
@@ -4054,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. */
|
||||
@@ -4097,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;
|
||||
@@ -4370,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++) {
|
||||
@@ -4394,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";
|
||||
@@ -4461,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8107,6 +8107,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",
|
||||
@@ -8452,6 +8460,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",
|
||||
@@ -8943,6 +8959,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-
|
||||
@@ -24976,6 +25330,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
|
||||
@@ -25533,6 +25923,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);
|
||||
@@ -25739,6 +26130,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);
|
||||
|
||||
+416
-49
@@ -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[] =
|
||||
@@ -6070,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");
|
||||
@@ -6126,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/'",
|
||||
@@ -6305,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");
|
||||
@@ -6316,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");
|
||||
@@ -7841,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));
|
||||
@@ -7878,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, ¤t_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);
|
||||
@@ -7945,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;
|
||||
@@ -11264,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. */
|
||||
@@ -11300,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);
|
||||
@@ -16005,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) &&
|
||||
@@ -16071,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"};
|
||||
@@ -18086,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;
|
||||
}
|
||||
@@ -18406,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);
|
||||
@@ -19415,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));
|
||||
@@ -19437,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];
|
||||
@@ -19450,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);
|
||||
@@ -19493,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
|
||||
@@ -28255,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);
|
||||
@@ -28399,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);
|
||||
@@ -28747,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user