chore: import upstream snapshot with attribution
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:28:05 +08:00
commit 41cb1c0170
1830 changed files with 38276124 additions and 0 deletions
+4949
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
/*
* cli.h — CLI subcommand handlers for install, uninstall, update, version.
*
* Port of Go cmd/codebase-memory-mcp/ install/update/config logic.
*
* Functions accept explicit paths (home_dir, binary_path) rather than
* reading HOME internally, making them testable with temp directories.
*/
#ifndef CBM_CLI_H
#define CBM_CLI_H
#include <stdbool.h>
/* ── Version ──────────────────────────────────────────────────── */
/* Set the version string (called from main). */
void cbm_cli_set_version(const char *ver);
/* Get the version string. */
const char *cbm_cli_get_version(void);
/* ── CLI tool arguments (flags / --args-file / --help) ────────── */
/* Convert `--flag value` / `--flag=value` / bare-boolean `--flag` arguments for
* a tool into a JSON arguments object string, using the tool's input_schema to
* type values (string/integer/boolean) and to collect repeated flags into
* array-typed properties. kebab-case flags map to snake_case keys
* (--repo-path -> repo_path). A bare `--` ends flag parsing. On error returns
* NULL and, if err_out is non-NULL, sets *err_out to a heap message the caller
* must free. Caller frees the returned JSON string. */
char *cbm_cli_build_args_json(const char *tool_name, int argc, char **argv, char **err_out);
/* Print per-tool help (usage + the tool's flags with type/description/required)
* derived from its input_schema, to stdout. Returns 0 if the tool is known,
* non-zero (and prints nothing) if it is not. */
int cbm_cli_print_tool_help(const char *tool_name);
/* ── Self-update: version comparison ──────────────────────────── */
/* Compare two semver strings (e.g. "0.2.1" vs "0.2.0").
* Returns >0 if a > b, <0 if a < b, 0 if equal.
* Handles v-prefix and -dev suffix. */
int cbm_compare_versions(const char *a, const char *b);
/* ── Shell RC detection ───────────────────────────────────────── */
/* Detect the appropriate shell RC file path for the current user.
* Uses SHELL env var. home_dir is the user's home directory.
* Returns static buffer — do NOT free. Returns "" if unknown. */
const char *cbm_detect_shell_rc(const char *home_dir);
/* ── CLI binary detection ─────────────────────────────────────── */
/* Find a CLI binary by name.
* Checks PATH first, then common install locations.
* Returns static buffer — do NOT free. Returns "" if not found. */
const char *cbm_find_cli(const char *name, const char *home_dir);
/* ── File utilities ───────────────────────────────────────────── */
/* Copy a file from src to dst. Returns 0 on success, -1 on error. */
int cbm_copy_file(const char *src, const char *dst);
/* Copy the running binary into the canonical install target, preserving the
* executable bit, skipping the copy when src and dst are the same file (which
* would otherwise truncate the running binary). Returns 0 on success or skip,
* -1 on error. Regression surface for the install --force binary-swap bug. */
int cbm_copy_binary_to_target(const char *src, const char *dst);
/* Replace a binary file: unlinks the existing file first (handles read-only),
* then creates a new file with the given data and permissions.
* Returns 0 on success, -1 on error. */
int cbm_replace_binary(const char *path, const unsigned char *data, int len, int mode);
/* ── Skill file management ────────────────────────────────────── */
/* Number of skill files. */
#define CBM_SKILL_COUNT 1
/* Skill name/content pair. */
typedef struct {
const char *name; /* e.g. "codebase-memory" */
const char *content; /* full SKILL.md content */
} cbm_skill_t;
/* Get the array of skill definitions. */
const cbm_skill_t *cbm_get_skills(void);
/* Install skills to skills_dir (e.g. ~/.claude/skills/).
* If force is true, overwrite existing skills.
* Returns count of skills written. */
int cbm_install_skills(const char *skills_dir, bool force, bool dry_run);
/* Remove skills from skills_dir.
* Returns count of skills removed. */
int cbm_remove_skills(const char *skills_dir, bool dry_run);
/* Remove old monolithic skill dir if it exists.
* Returns true if it was removed. */
bool cbm_remove_old_monolithic_skill(const char *skills_dir, bool dry_run);
/* ── Editor MCP config management ─────────────────────────────── */
/* Install MCP server entry in Cursor/Windsurf/Gemini JSON config.
* Format: { "mcpServers": { "codebase-memory-mcp": { "command": binary_path } } }
* Preserves existing entries. Returns 0 on success. */
int cbm_install_editor_mcp(const char *binary_path, const char *config_path);
/* Remove MCP server entry from Cursor/Windsurf/Gemini JSON config.
* Returns 0 on success. */
int cbm_remove_editor_mcp(const char *config_path);
/* Install MCP server entry in OpenClaw JSON config.
* Format: { "mcp": { "servers": { "codebase-memory-mcp":
* { "enabled": true, "command": binary_path, "args": [] } } } }
* Preserves existing entries. Returns 0 on success. */
int cbm_install_openclaw_mcp(const char *binary_path, const char *config_path);
/* Remove MCP server entry from OpenClaw JSON config.
* Returns 0 on success. */
int cbm_remove_openclaw_mcp(const char *config_path);
/* Install MCP server entry in VS Code JSON config.
* Format: { "servers": { "codebase-memory-mcp": { "type": "stdio", "command": binary_path } } }
* Returns 0 on success. */
int cbm_install_vscode_mcp(const char *binary_path, const char *config_path);
/* Remove MCP server entry from VS Code JSON config.
* Returns 0 on success. */
int cbm_remove_vscode_mcp(const char *config_path);
/* Install MCP server entry in Zed settings.json.
* Format: { "context_servers": { "codebase-memory-mcp": { "command": path, "args": [""] } } }
* Returns 0 on success. */
int cbm_install_zed_mcp(const char *binary_path, const char *config_path);
/* Remove MCP server entry from Zed settings.json.
* Returns 0 on success. */
int cbm_remove_zed_mcp(const char *config_path);
/* ── Agent detection ──────────────────────────────────────────── */
/* Detected coding agents on the system. */
typedef struct {
bool claude_code; /* ~/.claude/ exists */
bool codex; /* ~/.codex/ exists */
bool gemini; /* ~/.gemini/ exists */
bool zed; /* platform-specific Zed config dir exists */
bool opencode; /* opencode on PATH or config exists */
bool antigravity; /* ~/.gemini/antigravity/ exists */
bool aider; /* aider on PATH */
bool kilocode; /* KiloCode globalStorage dir exists */
bool vscode; /* VS Code User config dir exists */
bool cursor; /* ~/.cursor/ exists */
bool openclaw; /* ~/.openclaw/ exists */
bool kiro; /* ~/.kiro/ exists */
bool junie; /* ~/.junie/ exists */
} cbm_detected_agents_t;
/* Detect which coding agents are installed.
* Checks config dirs and PATH. home_dir is used for config dir checks. */
cbm_detected_agents_t cbm_detect_agents(const char *home_dir);
/* ── Agent MCP config upsert (per agent) ──────────────────────── */
/* Codex CLI: upsert MCP entry in ~/.codex/config.toml. Returns 0 on success. */
int cbm_upsert_codex_mcp(const char *binary_path, const char *config_path);
/* Remove CMM MCP entry from Codex config.toml. Returns 0 on success. */
int cbm_remove_codex_mcp(const char *config_path);
/* OpenCode: upsert MCP entry in opencode.json. Returns 0 on success. */
int cbm_upsert_opencode_mcp(const char *binary_path, const char *config_path);
/* Remove CMM MCP entry from opencode.json. Returns 0 on success. */
int cbm_remove_opencode_mcp(const char *config_path);
/* Antigravity: upsert MCP entry in ~/.gemini/antigravity/mcp_config.json.
* Returns 0 on success. */
int cbm_upsert_antigravity_mcp(const char *binary_path, const char *config_path);
/* Remove CMM MCP entry from antigravity mcp_config.json. Returns 0 on success. */
int cbm_remove_antigravity_mcp(const char *config_path);
/* Junie (JetBrains): upsert MCP entry in ~/.junie/mcp/mcp.json (mcpServers format).
* Returns 0 on success. */
int cbm_upsert_junie_mcp(const char *binary_path, const char *config_path);
/* Remove CMM MCP entry from Junie mcp.json. Returns 0 on success. */
int cbm_remove_junie_mcp(const char *config_path);
/* ── Instructions file upsert ─────────────────────────────────── */
/* Upsert a codebase-memory-mcp instruction section in a markdown file.
* Uses <!-- codebase-memory-mcp:start --> / <!-- codebase-memory-mcp:end --> markers.
* If markers exist, replaces content between them. Otherwise appends.
* If file doesn't exist, creates it. Returns 0 on success. */
int cbm_upsert_instructions(const char *path, const char *content);
/* Remove the codebase-memory-mcp instruction section from a markdown file.
* Returns 0 on success, 1 if not found. */
int cbm_remove_instructions(const char *path);
/* Get the shared agent instructions content (markdown). */
const char *cbm_get_agent_instructions(void);
/* #1032: Aider variant — CLI-form discovery commands (Aider has no MCP). */
const char *cbm_get_aider_instructions(void);
/* ── Pre-tool hook management ─────────────────────────────────── */
/* Upsert a PreToolUse hook in ~/.claude/settings.json for Claude Code.
* Adds a Grep|Glob matcher that reminds to use MCP tools.
* Returns 0 on success. */
int cbm_upsert_claude_hooks(const char *settings_path);
/* Remove our PreToolUse hook from Claude Code settings.json.
* Returns 0 on success. */
int cbm_remove_claude_hooks(const char *settings_path);
/* Write the PreToolUse gate shim to <home>/.claude/hooks/. The shim is a thin
* wrapper that invokes the compiled `hook-augment` and writes to stdout only —
* it must never create a predictable temp/state file (issue #384). Exposed for
* testing that security property. */
void cbm_install_hook_gate_script(const char *home, const char *binary_path);
/* Upsert a BeforeTool hook in ~/.gemini/settings.json for Gemini CLI / Antigravity.
* Returns 0 on success. */
int cbm_upsert_gemini_hooks(const char *settings_path);
/* Remove our BeforeTool hook from Gemini settings.json.
* Returns 0 on success. */
int cbm_remove_gemini_hooks(const char *settings_path);
/* Install/remove a SessionStart reminder hook in Codex config.toml (#330) and
* Gemini/Antigravity settings.json — same methodology as the Claude Code
* SessionStart hook (non-blocking; stdout injected as session context). */
int cbm_upsert_codex_hooks(const char *config_path);
int cbm_remove_codex_hooks(const char *config_path);
int cbm_upsert_gemini_session_hooks(const char *settings_path);
int cbm_remove_gemini_session_hooks(const char *settings_path);
/* Install/remove a Claude Code SubagentStart reminder hook in settings.json.
* Subagents spawned via the Agent tool do not fire SessionStart, so this is the
* channel that gives them the same code-discovery guidance. Non-blocking; the
* hook injects context via JSON additionalContext. Returns 0 on success. */
int cbm_upsert_claude_subagent_hooks(const char *settings_path);
int cbm_remove_claude_subagent_hooks(const char *settings_path);
/* ── PATH management ──────────────────────────────────────────── */
/* Append an export PATH line to the given rc file.
* Checks if already present. Returns 0 on success, 1 if already present. */
int cbm_ensure_path(const char *bin_dir, const char *rc_file, bool dry_run);
/* ── Codex instructions (legacy, wraps cbm_get_agent_instructions) ── */
/* Get the Codex CLI instructions content. */
const char *cbm_get_codex_instructions(void);
/* ── Tar.gz extraction ────────────────────────────────────────── */
/* Extract a binary named "codebase-memory-mcp*" from a tar.gz buffer.
* Returns malloc'd binary content and sets *out_len.
* Returns NULL on error. Caller must free. */
unsigned char *cbm_extract_binary_from_targz(const unsigned char *data, int data_len, int *out_len);
/* Extract the codebase-memory-mcp binary from a zip archive in memory.
* Returns malloc'd binary content and sets *out_len.
* Returns NULL on error. Caller must free. */
unsigned char *cbm_extract_binary_from_zip(const unsigned char *data, int data_len, int *out_len);
/* ── Index management ─────────────────────────────────────────── */
/* List .db files in the cache directory (~/.cache/codebase-memory-mcp/).
* Prints each file path to stdout. Returns count of .db files found. */
int cbm_list_indexes(const char *home_dir);
/* Remove all .db files in the cache directory. Returns count removed. */
int cbm_remove_indexes(const char *home_dir);
/* ── Config store (persistent key-value, backed by _config.db) ── */
typedef struct cbm_config cbm_config_t;
/* Open the config store in the given cache directory.
* Creates _config.db if it doesn't exist. Returns NULL on error. */
cbm_config_t *cbm_config_open(const char *cache_dir);
/* Close the config store. */
void cbm_config_close(cbm_config_t *cfg);
/* Get a config value. Returns default_val if key not found. */
const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *default_val);
/* Get a config value as bool. "true"/"1"/"on" → true. */
bool cbm_config_get_bool(cbm_config_t *cfg, const char *key, bool default_val);
/* Get a config value as int. Returns default_val if not found or invalid. */
int cbm_config_get_int(cbm_config_t *cfg, const char *key, int default_val);
/* Set a config value. Returns 0 on success. */
int cbm_config_set(cbm_config_t *cfg, const char *key, const char *value);
/* Delete a config key. Returns 0 on success. */
int cbm_config_delete(cbm_config_t *cfg, const char *key);
/* Well-known config keys */
#define CBM_CONFIG_AUTO_INDEX "auto_index"
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
#define CBM_CONFIG_UI_LANG "ui-lang"
/* ── Subcommands (wired from main.c) ─────────────────────────── */
/* install: copy binary, install skills, install editor MCP configs, ensure PATH.
* Prompts to delete old indexes if any exist — rejects on "no". */
int cbm_cmd_install(int argc, char **argv);
/* uninstall: remove skills, remove editor MCP configs, remove binary. */
int cbm_cmd_uninstall(int argc, char **argv);
/* update: check latest release, prompt for index deletion, prompt for ui/standard,
* download and replace binary. */
int cbm_cmd_update(int argc, char **argv);
/* config: get/set/list/reset runtime config values. */
int cbm_cmd_config(int argc, char **argv);
/* hook-augment: stdin-driven Claude Code PreToolUse augmenter.
* Reads the hook JSON from stdin and emits hookSpecificOutput.additionalContext
* with search_graph hits for Grep/Glob calls. NEVER blocks: every failure
* path returns 0 with no stdout output. */
int cbm_cmd_hook_augment(void);
/* True for an absolute path the augmenter can walk up: POSIX "/..." or a
* Windows drive root — "X:/..." or a bare "X:" (callers normalize '\\' to '/'
* first). Exposed for tests — regression coverage for the Windows drive-letter
* no-op (#618). */
bool cbm_hook_path_is_abs(const char *path);
/* Build the agent.install.plan.v1 install receipt for <home> (issue #388):
* a machine-readable JSON list of the config/instruction/hook files `install`
* would write, produced WITHOUT mutating anything. Returns a heap JSON string
* (caller frees) or NULL on error. Exposed for `install --plan` and testing. */
char *cbm_build_install_plan_json(const char *home, const char *binary_path);
#endif /* CBM_CLI_H */
+631
View File
@@ -0,0 +1,631 @@
/*
* hook_augment.c — `codebase-memory-mcp hook-augment`
*
* A non-blocking Claude Code PreToolUse augmenter. Reads the hook JSON from
* stdin, and for Grep/Glob calls injects matching graph symbols as
* `additionalContext` so the agent gets structured context alongside its
* normal search results.
*
* Cardinal rule: this NEVER blocks a tool call. Every error, timeout, missing
* project, or short/odd pattern path results in `exit 0` with NO stdout
* output (a clean pass-through). This is what makes issue #362 structurally
* impossible to recur — the hook cannot deny a tool.
*
* The underlying query is `search_graph` (pure SQLite, shell-free) — chosen
* over `search_code` (which shells out to grep|xargs) so the hook stays cheap
* enough to run before every Grep/Glob.
*/
#include "cli/cli.h"
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/mem.h"
#include "mcp/mcp.h"
#include "pipeline/pipeline.h"
#include "yyjson/yyjson.h"
#include <ctype.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <fcntl.h>
#include <signal.h>
#include <sys/time.h>
#include <unistd.h>
#endif
#define HA_STDIN_CAP (256 * 1024) /* hook payloads are tiny; cap defensively */
#define HA_MIN_TOKEN 4 /* skip short/noisy patterns before any work */
#define HA_MAX_TOKEN 96
#define HA_RESULT_LIMIT 5
#define HA_MAX_WALKUP 8 /* cwd may be a subdir of the indexed root */
/* ── Hard deadline ────────────────────────────────────────────────
* A slow SQLite open or query must never stall the agent. When the timer
* fires we _exit(0) immediately. Output is written exactly once at the very
* end, so firing mid-work simply yields a clean no-op (no partial JSON).
*
* Observability (#858): a fired deadline is otherwise indistinguishable from
* "no matches", so the handler first write()s a pre-formatted breadcrumb to
* ~/.cache/codebase-memory-mcp/logs/hook-augment-timeouts.log (fd and message
* prepared at arm time — only async-signal-safe write/_exit in the handler). */
#ifndef _WIN32
#define HA_DEADLINE_DEFAULT_MS 2000 /* in-process budget; see ha_deadline_ms() */
#define HA_DEADLINE_MIN_MS 50
#define HA_DEADLINE_MAX_MS 10000
/* #858: the original 300ms budget silently self-terminated on real cold
* starts (SQLite/mmap open under load), so augmentation never appeared in
* real sessions (0/24 observed) while manual warm invocations worked. The
* budget is now generous by default and env-configurable; the settings.json
* hook "timeout" remains the outer backstop (and alone governs Windows,
* where this whole in-process deadline block is compiled out). */
static int ha_deadline_ms(void) {
const char *env = getenv("CBM_HOOK_DEADLINE_MS");
if (!env || !env[0]) {
return HA_DEADLINE_DEFAULT_MS;
}
int v = atoi(env);
if (v < HA_DEADLINE_MIN_MS) {
return HA_DEADLINE_MIN_MS;
}
if (v > HA_DEADLINE_MAX_MS) {
return HA_DEADLINE_MAX_MS;
}
return v;
}
static int g_ha_crumb_fd = -1;
static char g_ha_crumb_msg[160];
static size_t g_ha_crumb_len = 0;
static void ha_deadline_exit(int sig) {
(void)sig;
if (g_ha_crumb_fd >= 0 && g_ha_crumb_len > 0) {
ssize_t w = write(g_ha_crumb_fd, g_ha_crumb_msg, g_ha_crumb_len);
(void)w;
}
_exit(0);
}
static void ha_open_crumb_log(int deadline_ms) {
const char *override = getenv("CBM_HOOK_TIMEOUT_LOG"); /* tests + power users */
char path[CBM_SZ_1K];
if (override && override[0]) {
snprintf(path, sizeof(path), "%s", override);
} else {
const char *home = getenv("HOME");
if (!home || !home[0]) {
return;
}
char dir[CBM_SZ_1K];
snprintf(dir, sizeof(dir), "%s/.cache/codebase-memory-mcp/logs", home);
cbm_mkdir_p(dir, 0755);
snprintf(path, sizeof(path), "%s/hook-augment-timeouts.log", dir);
}
g_ha_crumb_fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0644);
if (g_ha_crumb_fd < 0) {
return;
}
int n = snprintf(g_ha_crumb_msg, sizeof(g_ha_crumb_msg),
"hook-augment: deadline_exceeded ms=%d pid=%ld (raise via "
"CBM_HOOK_DEADLINE_MS)\n",
deadline_ms, (long)getpid());
g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0;
}
static void ha_arm_deadline(void) {
int ms = ha_deadline_ms();
ha_open_crumb_log(ms);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = ha_deadline_exit;
sigaction(SIGALRM, &sa, NULL);
struct itimerval it;
memset(&it, 0, sizeof(it));
it.it_value.tv_sec = ms / 1000;
it.it_value.tv_usec = (ms % 1000) * 1000;
setitimer(ITIMER_REAL, &it, NULL);
}
#else
static void ha_arm_deadline(void) { /* Windows: rely on settings.json timeout */ }
#endif
/* ── stdin ────────────────────────────────────────────────────────── */
static char *ha_read_stdin(void) {
char *buf = malloc(HA_STDIN_CAP + 1);
if (!buf) {
return NULL;
}
size_t total = 0;
size_t n;
while (total < HA_STDIN_CAP && (n = fread(buf + total, 1, HA_STDIN_CAP - total, stdin)) > 0) {
total += n;
}
buf[total] = '\0';
return buf;
}
/* ── pattern → token ──────────────────────────────────────────────
* Extract the longest identifier-like run ([A-Za-z_][A-Za-z0-9_]*) of at
* least HA_MIN_TOKEN chars. Pure-identifier output means it is always safe
* to embed in a regex (name_pattern) with no escaping. Returns false when
* the pattern has no usable token (path globs, short/regex-only patterns) —
* the caller then no-ops, which keeps the common cheap case cheap. */
static bool ha_extract_token(const char *pattern, char *out, size_t out_sz) {
if (!pattern) {
return false;
}
size_t best_start = 0;
size_t best_len = 0;
size_t i = 0;
while (pattern[i]) {
if (isalpha((unsigned char)pattern[i]) || pattern[i] == '_') {
size_t start = i;
while (pattern[i] && (isalnum((unsigned char)pattern[i]) || pattern[i] == '_')) {
i++;
}
size_t len = i - start;
if (len > best_len) {
best_len = len;
best_start = start;
}
} else {
i++;
}
}
if (best_len < HA_MIN_TOKEN) {
return false;
}
if (best_len > HA_MAX_TOKEN) {
best_len = HA_MAX_TOKEN;
}
if (best_len + 1 > out_sz) {
best_len = out_sz - 1;
}
memcpy(out, pattern + best_start, best_len);
out[best_len] = '\0';
return true;
}
/* ── JSON helpers ─────────────────────────────────────────────────── */
static const char *ha_obj_str(yyjson_val *obj, const char *key) {
yyjson_val *v = obj ? yyjson_obj_get(obj, key) : NULL;
return (v && yyjson_is_str(v)) ? yyjson_get_str(v) : NULL;
}
/* Build the search_graph args JSON: {"project":..,"name_pattern":".*tok.*",
* "limit":N}. `token` is a pure identifier so regex embedding is safe. */
static char *ha_build_args(const char *project, const char *token) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
char name_pattern[HA_MAX_TOKEN + 8];
snprintf(name_pattern, sizeof(name_pattern), ".*%s.*", token);
yyjson_mut_obj_add_str(doc, root, "project", project);
yyjson_mut_obj_add_str(doc, root, "name_pattern", name_pattern);
yyjson_mut_obj_add_int(doc, root, "limit", HA_RESULT_LIMIT);
/* Programmatic consumer: search_graph defaults to TOON text, but
* ha_format_context parses the inner payload as JSON ("results"). */
yyjson_mut_obj_add_str(doc, root, "format", "json");
char *out = yyjson_mut_write(doc, 0, NULL);
yyjson_mut_doc_free(doc);
return out; /* caller frees */
}
/* Parse the MCP envelope returned by cbm_mcp_handle_tool and, if it is a
* successful search_graph result with >=1 hit, format a compact
* additionalContext string. Returns malloc'd text or NULL.
*
* *is_error is set when the envelope is an MCP error (e.g. project not
* indexed) so the caller can try a parent directory. */
static char *ha_format_context(const char *envelope, const char *token, bool *is_error) {
*is_error = false;
yyjson_doc *edoc = yyjson_read(envelope, strlen(envelope), 0);
if (!edoc) {
return NULL;
}
yyjson_val *eroot = yyjson_doc_get_root(edoc);
yyjson_val *err = yyjson_obj_get(eroot, "isError");
if (err && yyjson_is_true(err)) {
*is_error = true;
yyjson_doc_free(edoc);
return NULL;
}
yyjson_val *content = yyjson_obj_get(eroot, "content");
yyjson_val *item0 = (content && yyjson_is_arr(content)) ? yyjson_arr_get(content, 0) : NULL;
const char *inner = ha_obj_str(item0, "text");
if (!inner) {
yyjson_doc_free(edoc);
return NULL;
}
yyjson_doc *idoc = yyjson_read(inner, strlen(inner), 0);
if (!idoc) {
yyjson_doc_free(edoc);
return NULL;
}
yyjson_val *iroot = yyjson_doc_get_root(idoc);
yyjson_val *results = yyjson_obj_get(iroot, "results");
size_t nres = (results && yyjson_is_arr(results)) ? yyjson_arr_size(results) : 0;
if (nres == 0) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
return NULL; /* valid project, just no matching symbols */
}
char *text = malloc(4096);
if (!text) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
return NULL;
}
int off = snprintf(text, 4096,
"[codebase-memory] %zu graph symbol(s) match \"%s\" "
"(structured context; your search results below are "
"unaffected):",
nres, token);
size_t idx;
size_t maxn;
yyjson_val *r;
yyjson_arr_foreach(results, idx, maxn, r) {
if (off < 0 || off >= 3900) {
break;
}
const char *qn = ha_obj_str(r, "qualified_name");
const char *nm = ha_obj_str(r, "name");
const char *fp = ha_obj_str(r, "file_path");
const char *lb = ha_obj_str(r, "label");
const char *disp = (qn && qn[0]) ? qn : (nm ? nm : "");
off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", disp, fp ? fp : "",
(lb && lb[0]) ? " " : "", (lb && lb[0]) ? lb : "");
}
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
return text;
}
/* ── Read coverage note (#963) ────────────────────────────────────
* For Read calls: if the file being read is listed in the project's
* index_coverage table (parse_partial or a skip), inject a note so the agent
* knows the knowledge graph may under-report this file. Best-effort and
* non-blocking like everything else here — no entry, no output. */
/* Parse an index_status envelope (which carries the coverage report) and
* return a note when `rel` is listed.
* *is_error is set for MCP errors (project not indexed) → caller climbs. */
static char *ha_coverage_context(const char *envelope, const char *rel, bool *is_error) {
*is_error = false;
yyjson_doc *edoc = yyjson_read(envelope, strlen(envelope), 0);
if (!edoc) {
return NULL;
}
yyjson_val *eroot = yyjson_doc_get_root(edoc);
yyjson_val *err = yyjson_obj_get(eroot, "isError");
if (err && yyjson_is_true(err)) {
*is_error = true;
yyjson_doc_free(edoc);
return NULL;
}
yyjson_val *content = yyjson_obj_get(eroot, "content");
yyjson_val *item0 = (content && yyjson_is_arr(content)) ? yyjson_arr_get(content, 0) : NULL;
const char *inner = ha_obj_str(item0, "text");
if (!inner) {
yyjson_doc_free(edoc);
return NULL;
}
yyjson_doc *idoc = yyjson_read(inner, strlen(inner), 0);
if (!idoc) {
yyjson_doc_free(edoc);
return NULL;
}
yyjson_val *iroot = yyjson_doc_get_root(idoc);
char *text = NULL;
yyjson_val *pp = yyjson_obj_get(iroot, "parse_partial");
yyjson_val *files = pp ? yyjson_obj_get(pp, "files") : NULL;
size_t idx;
size_t maxn;
yyjson_val *fe;
if (files && yyjson_is_arr(files)) {
yyjson_arr_foreach(files, idx, maxn, fe) {
const char *fp = ha_obj_str(fe, "path");
if (fp && strcmp(fp, rel) == 0) {
const char *ranges = ha_obj_str(fe, "error_ranges");
text = malloc(1024);
if (text) {
snprintf(text, 1024,
"[codebase-memory] Coverage note: this file was only PARTIALLY "
"indexed — line range(s) %s could not be parsed, so constructs "
"there may be missing from the knowledge graph. The file content "
"you are reading is ground truth; graph queries may under-report "
"this file. (best-effort signal)",
ranges && ranges[0] ? ranges : "?");
}
break;
}
}
}
if (!text) {
yyjson_val *sk = yyjson_obj_get(iroot, "skipped");
files = sk ? yyjson_obj_get(sk, "files") : NULL;
if (files && yyjson_is_arr(files)) {
yyjson_arr_foreach(files, idx, maxn, fe) {
const char *fp = ha_obj_str(fe, "path");
if (fp && strcmp(fp, rel) == 0) {
const char *phase = ha_obj_str(fe, "phase");
const char *reason = ha_obj_str(fe, "reason");
text = malloc(1024);
if (text) {
snprintf(text, 1024,
"[codebase-memory] Coverage note: this file was NOT indexed "
"(%s%s%s) — the knowledge graph has no data for it. "
"(best-effort signal)",
phase ? phase : "skipped", reason && reason[0] ? ": " : "",
reason ? reason : "");
}
break;
}
}
}
}
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
return text;
}
/* Strip the last path component in place. Returns false at a filesystem or
* drive root (nothing left to strip). */
static bool ha_strip_last_component(char *dir) {
char *slash = strrchr(dir, '/');
if (!slash || slash == dir) {
return false; /* POSIX root "/" */
}
if (slash == dir + 2 && dir[1] == ':') {
return false; /* Windows drive root "X:/" — don't strip to "X:" */
}
*slash = '\0';
return true;
}
/* Walk up from the file's parent directory to find the indexed project, then
* check whether the file (repo-relative) is listed in its coverage report.
* Mirrors ha_resolve_and_query: an MCP error means "not indexed here" →
* climb; a valid project with no entry for this file → stop, no output. */
static char *ha_resolve_coverage(cbm_mcp_server_t *srv, const char *file_path) {
char dir[4096];
snprintf(dir, sizeof(dir), "%s", file_path);
if (!ha_strip_last_component(dir)) {
return NULL; /* file directly at a root — nothing to resolve against */
}
for (int level = 0; level < HA_MAX_WALKUP && cbm_hook_path_is_abs(dir); level++) {
char *project = cbm_project_name_from_path(dir);
if (project) {
yyjson_mut_doc *adoc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *aroot = yyjson_mut_obj(adoc);
yyjson_mut_doc_set_root(adoc, aroot);
yyjson_mut_obj_add_str(adoc, aroot, "project", project);
char *args = yyjson_mut_write(adoc, 0, NULL);
yyjson_mut_doc_free(adoc);
free(project);
if (args) {
char *res = cbm_mcp_handle_tool(srv, "index_status", args);
free(args);
if (res) {
bool is_error = false;
const char *rel = file_path + strlen(dir) + 1;
char *ctx = ha_coverage_context(res, rel, &is_error);
free(res);
if (ctx) {
return ctx; /* listed → note */
}
if (!is_error) {
return NULL; /* indexed project, file not listed → stop */
}
}
}
}
if (!ha_strip_last_component(dir)) {
break;
}
}
return NULL;
}
/* Emit the PreToolUse additionalContext payload to stdout (exactly once). */
static void ha_emit(const char *text) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_val *hso = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, hso, "hookEventName", "PreToolUse");
yyjson_mut_obj_add_str(doc, hso, "additionalContext", text);
yyjson_mut_obj_add_val(doc, root, "hookSpecificOutput", hso);
char *json = yyjson_mut_write(doc, 0, NULL);
if (json) {
fputs(json, stdout);
free(json);
}
yyjson_mut_doc_free(doc);
}
/* True for an absolute path we can walk up: POSIX "/..." or a Windows drive
* root — "X:/..." or a bare "X:" (callers normalize '\\' to '/' first).
* Declared in cli.h so the Windows drive-letter handling (#618) has direct
* regression coverage. */
bool cbm_hook_path_is_abs(const char *d) {
if (!d || !d[0]) {
return false;
}
if (d[0] == '/') {
return true;
}
return isalpha((unsigned char)d[0]) && d[1] == ':' && (d[2] == '/' || d[2] == '\0');
}
/* Walk up from `start`, deriving a project name at each level and querying
* search_graph until an indexed project is found (or the walk is exhausted).
* Stops at the first non-error result: a valid project with zero hits is a
* legitimate "no match" and must NOT cause a parent-directory probe. */
static char *ha_resolve_and_query(cbm_mcp_server_t *srv, const char *start, const char *token) {
char dir[4096];
snprintf(dir, sizeof(dir), "%s", start);
for (int level = 0; level < HA_MAX_WALKUP && cbm_hook_path_is_abs(dir); level++) {
char *project = cbm_project_name_from_path(dir);
if (project) {
char *args = ha_build_args(project, token);
free(project);
if (args) {
char *res = cbm_mcp_handle_tool(srv, "search_graph", args);
free(args);
if (res) {
bool is_error = false;
char *ctx = ha_format_context(res, token, &is_error);
free(res);
if (ctx) {
return ctx; /* hits → done */
}
if (!is_error) {
return NULL; /* valid project, no hits → stop */
}
}
}
}
/* Not indexed at this level — climb to the parent. */
char *slash = strrchr(dir, '/');
if (!slash || slash == dir) {
break; /* POSIX root "/" */
}
if (slash == dir + 2 && dir[1] == ':') {
break; /* Windows drive root "X:/" — don't strip to "X:" */
}
*slash = '\0';
}
return NULL;
}
int cbm_cmd_hook_augment(void) {
ha_arm_deadline();
char *input = ha_read_stdin();
if (!input) {
return 0;
}
yyjson_doc *doc = yyjson_read(input, strlen(input), 0);
if (!doc) {
free(input);
return 0;
}
yyjson_val *root = yyjson_doc_get_root(doc);
const char *tool = ha_obj_str(root, "tool_name");
if (!tool ||
(strcmp(tool, "Grep") != 0 && strcmp(tool, "Glob") != 0 && strcmp(tool, "Read") != 0)) {
yyjson_doc_free(doc);
free(input);
return 0;
}
yyjson_val *tin = yyjson_obj_get(root, "tool_input");
/* Read → coverage note (#963): warn when the file being read is listed as
* not fully indexed. Independent of the Grep/Glob symbol augment below. */
if (strcmp(tool, "Read") == 0) {
const char *fp = ha_obj_str(tin, "file_path");
char fpbuf[4096];
if (fp) {
snprintf(fpbuf, sizeof(fpbuf), "%s", fp);
for (char *p = fpbuf; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
}
if (fp && cbm_hook_path_is_abs(fpbuf)) {
cbm_mcp_server_t *rsrv = cbm_mcp_server_new(NULL);
if (rsrv) {
char *note = ha_resolve_coverage(rsrv, fpbuf);
if (note) {
ha_emit(note);
free(note);
}
cbm_mcp_server_free(rsrv);
}
}
yyjson_doc_free(doc);
free(input);
return 0;
}
const char *pattern = ha_obj_str(tin, "pattern");
char token[HA_MAX_TOKEN + 1];
if (!ha_extract_token(pattern, token, sizeof(token))) {
yyjson_doc_free(doc);
free(input);
return 0;
}
const char *cwd = ha_obj_str(root, "cwd");
char cwdbuf[4096];
#ifndef _WIN32
if (!cwd || !cbm_hook_path_is_abs(cwd)) {
if (!getcwd(cwdbuf, sizeof(cwdbuf))) {
yyjson_doc_free(doc);
free(input);
return 0;
}
cwd = cwdbuf;
}
#else
/* Windows: Claude Code passes an absolute drive-letter cwd in the hook
* payload (e.g. C:\repo). Normalize '\\' -> '/' and require an absolute
* path; the walk-up loop handles POSIX and "X:/..." roots alike. Without
* a usable cwd there is nothing to augment — fail open cleanly. */
if (cwd) {
snprintf(cwdbuf, sizeof(cwdbuf), "%s", cwd);
for (char *p = cwdbuf; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
cwd = cwdbuf;
}
if (!cwd || !cbm_hook_path_is_abs(cwd)) {
yyjson_doc_free(doc);
free(input);
return 0;
}
#endif
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
yyjson_doc_free(doc);
free(input);
return 0;
}
char *ctx = ha_resolve_and_query(srv, cwd, token);
if (ctx) {
ha_emit(ctx);
free(ctx);
}
cbm_mcp_server_free(srv);
yyjson_doc_free(doc);
free(input);
return 0;
}
+217
View File
@@ -0,0 +1,217 @@
/*
* progress_sink.c — Human-readable progress for --progress CLI flag.
*
* Maps structured log events (msg=pass.timing, msg=pipeline.done, etc.)
* to phase labels on stderr. When installed, replaces default log output.
*
* Thread-safe: fprintf has per-FILE* locking on POSIX.
*/
#include "progress_sink.h"
#include "foundation/constants.h"
#include "foundation/log.h"
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum { PERCENT = 100, NOT_SET = -1 };
static FILE *s_out;
static atomic_int s_needs_newline;
static int s_gbuf_nodes = NOT_SET;
static int s_gbuf_edges = NOT_SET;
/* Extract value of "key=VALUE" from a structured log line. */
static const char *extract_kv(const char *line, const char *key, char *buf, int buf_len) {
if (!line || !key || !buf || buf_len <= 0) {
return NULL;
}
size_t klen = strlen(key);
const char *p = line;
while (*p) {
if ((p == line || p[-SKIP_ONE] == ' ') && strncmp(p, key, klen) == 0 && p[klen] == '=') {
const char *val = p + klen + SKIP_ONE;
int i = 0;
while (val[i] && val[i] != ' ' && i < buf_len - SKIP_ONE) {
buf[i] = val[i];
i++;
}
buf[i] = '\0';
return buf;
}
p++;
}
return NULL;
}
void cbm_progress_sink_init(FILE *out) {
s_out = out ? out : stderr;
atomic_store(&s_needs_newline, 0);
s_gbuf_nodes = NOT_SET;
s_gbuf_edges = NOT_SET;
cbm_log_set_sink(cbm_progress_sink_fn);
}
void cbm_progress_sink_fini(void) {
if (atomic_load(&s_needs_newline) && s_out) {
(void)fprintf(s_out, "\n");
(void)fflush(s_out);
}
cbm_log_set_sink(NULL);
s_out = NULL;
}
/* Phase label table: maps pass names to display labels. */
typedef struct {
const char *pass;
const char *label;
} phase_t;
static const phase_t phases[] = {
{"parallel_extract", "[2/9] Extracting definitions"},
{"registry_build", "[3/9] Building registry"},
{"parallel_resolve", "[4/9] Resolving calls & edges"},
{"tests", "[5/9] Detecting tests"},
{"httplinks", "[6/9] Scanning HTTP links"},
{"githistory_compute", "[7/9] Analyzing git history"},
{"configlink", "[8/9] Linking config files"},
{"dump", "[9/9] Writing database"},
};
enum { PHASE_COUNT = sizeof(phases) / sizeof(phases[0]) };
/* Flush pending \r line if needed. */
static void flush_carriage(void) {
if (atomic_load(&s_needs_newline)) {
(void)fprintf(s_out, "\n");
atomic_store(&s_needs_newline, 0);
}
}
/* Handle pipeline.discover event. */
static void on_discover(const char *line) {
char files[CBM_SZ_32] = {0};
if (extract_kv(line, "files", files, (int)sizeof(files))) {
(void)fprintf(s_out, " Discovering files (%s found)\n", files);
} else {
(void)fprintf(s_out, " Discovering files...\n");
}
(void)fflush(s_out);
}
/* Handle pipeline.route event. */
static void on_route(const char *line) {
char val[CBM_SZ_32] = {0};
const char *path = extract_kv(line, "path", val, (int)sizeof(val));
if (path && strcmp(path, "incremental") == 0) {
(void)fprintf(s_out, " Starting incremental index\n");
} else {
(void)fprintf(s_out, " Starting full index\n");
}
(void)fflush(s_out);
}
/* Handle pass.start event. */
static void on_pass_start(const char *line) {
char val[CBM_SZ_64] = {0};
const char *pass = extract_kv(line, "pass", val, (int)sizeof(val));
if (pass && strcmp(pass, "structure") == 0) {
(void)fprintf(s_out, "[1/9] Building file structure\n");
(void)fflush(s_out);
}
}
/* Handle pass.timing event. */
static void on_pass_timing(const char *line) {
char val[CBM_SZ_64] = {0};
const char *pass = extract_kv(line, "pass", val, (int)sizeof(val));
if (!pass) {
return;
}
flush_carriage();
for (int i = 0; i < PHASE_COUNT; i++) {
if (strcmp(pass, phases[i].pass) == 0) {
(void)fprintf(s_out, "%s\n", phases[i].label);
(void)fflush(s_out);
return;
}
}
}
/* Handle gbuf.dump event — capture node/edge counts. */
static void on_gbuf_dump(const char *line) {
char n[CBM_SZ_32] = {0};
char e[CBM_SZ_32] = {0};
if (extract_kv(line, "nodes", n, (int)sizeof(n))) {
s_gbuf_nodes = (int)strtol(n, NULL, CBM_DECIMAL_BASE);
}
if (extract_kv(line, "edges", e, (int)sizeof(e))) {
s_gbuf_edges = (int)strtol(e, NULL, CBM_DECIMAL_BASE);
}
}
/* Handle pipeline.done event. */
static void on_done(const char *line) {
flush_carriage();
char ms[CBM_SZ_32] = {0};
const char *elapsed = extract_kv(line, "elapsed_ms", ms, (int)sizeof(ms));
if (s_gbuf_nodes >= 0 && s_gbuf_edges >= 0 && elapsed) {
(void)fprintf(s_out, "Done: %d nodes, %d edges (%s ms)\n", s_gbuf_nodes, s_gbuf_edges,
elapsed);
} else if (s_gbuf_nodes >= 0 && s_gbuf_edges >= 0) {
(void)fprintf(s_out, "Done: %d nodes, %d edges\n", s_gbuf_nodes, s_gbuf_edges);
} else {
(void)fprintf(s_out, "Done.\n");
}
(void)fflush(s_out);
}
/* Handle parallel.extract.progress event — in-place counter. */
static void on_extract_progress(const char *line) {
char done[CBM_SZ_32] = {0};
char total[CBM_SZ_32] = {0};
if (extract_kv(line, "done", done, (int)sizeof(done)) &&
extract_kv(line, "total", total, (int)sizeof(total))) {
long d = strtol(done, NULL, CBM_DECIMAL_BASE);
long t = strtol(total, NULL, CBM_DECIMAL_BASE);
int pct = (t > 0) ? (int)((d * PERCENT) / t) : 0;
(void)fprintf(s_out, "\r Extracting: %ld/%ld files (%d%%)", d, t, pct);
(void)fflush(s_out);
atomic_store(&s_needs_newline, SKIP_ONE);
}
}
/* Event dispatch table. */
typedef struct {
const char *msg;
void (*handler)(const char *line);
} event_handler_t;
static const event_handler_t handlers[] = {
{"pipeline.discover", on_discover},
{"pipeline.route", on_route},
{"pass.start", on_pass_start},
{"pass.timing", on_pass_timing},
{"gbuf.dump", on_gbuf_dump},
{"pipeline.done", on_done},
{"parallel.extract.progress", on_extract_progress},
};
enum { HANDLER_COUNT = sizeof(handlers) / sizeof(handlers[0]) };
void cbm_progress_sink_fn(const char *line) {
if (!line || !s_out) {
return;
}
char msg[CBM_SZ_64] = {0};
if (!extract_kv(line, "msg", msg, (int)sizeof(msg))) {
return;
}
for (int i = 0; i < HANDLER_COUNT; i++) {
if (strcmp(msg, handlers[i].msg) == 0) {
handlers[i].handler(line);
return;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* progress_sink.h — Human-readable progress output for --progress CLI flag.
*
* Installs a log sink that maps structured pipeline events to phase labels.
* Usage:
* cbm_progress_sink_init(stderr);
* // ... run pipeline ...
* cbm_progress_sink_fini();
*/
#ifndef CBM_PROGRESS_SINK_H
#define CBM_PROGRESS_SINK_H
#include <stdio.h>
void cbm_progress_sink_init(FILE *out);
void cbm_progress_sink_fini(void);
void cbm_progress_sink_fn(const char *line);
#endif
+4617
View File
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
/*
* cypher.h — Public API for the Cypher query engine.
*
* Provides lexing, parsing, planning, and execution of a subset of
* Cypher queries against the cbm_store graph database.
*
* Supported syntax:
* MATCH (n:Label)-[:TYPE*1..3]->(m:Label {prop: "val"})
* WHERE n.name =~ ".*pattern.*" AND m.label = "Function"
* RETURN n.name, COUNT(m) AS cnt ORDER BY cnt DESC LIMIT 10
*/
#ifndef CBM_CYPHER_H
#define CBM_CYPHER_H
#include <stdint.h>
#include <stdbool.h>
#include <store/store.h>
/* ── Token types ────────────────────────────────────────────────── */
typedef enum {
/* Keywords */
TOK_MATCH,
TOK_WHERE,
TOK_RETURN,
TOK_ORDER,
TOK_BY,
TOK_LIMIT,
TOK_AND,
TOK_OR,
TOK_AS,
TOK_DISTINCT,
TOK_COUNT,
TOK_CONTAINS,
TOK_STARTS,
TOK_WITH,
TOK_NOT,
TOK_ASC,
TOK_DESC,
TOK_NEQ, /* <> or != */
TOK_ENDS, /* ENDS (as in ENDS WITH) */
TOK_IN,
TOK_IS,
TOK_NULL_KW, /* NULL keyword */
TOK_XOR,
TOK_SKIP,
TOK_UNION,
TOK_UNWIND,
/* Aggregate functions */
TOK_SUM,
TOK_AVG,
TOK_MIN_KW,
TOK_MAX_KW,
TOK_COLLECT,
/* String functions — recognized as keywords */
TOK_TOLOWER,
TOK_TOUPPER,
TOK_TOSTRING,
/* CASE expression */
TOK_CASE,
TOK_WHEN,
TOK_THEN,
TOK_ELSE,
TOK_END,
/* Recognized-but-unsupported write/admin keywords */
TOK_CREATE,
TOK_DELETE,
TOK_DETACH,
TOK_SET,
TOK_REMOVE,
TOK_MERGE,
TOK_OPTIONAL,
TOK_YIELD,
TOK_CALL,
TOK_ALL,
TOK_TRUE,
TOK_FALSE,
TOK_EXISTS,
TOK_MANDATORY,
TOK_FOREACH,
TOK_ON,
TOK_ADD,
TOK_CONSTRAINT,
TOK_DO,
TOK_DROP,
TOK_FOR,
TOK_FROM,
TOK_GRAPH,
TOK_OF,
TOK_REQUIRE,
TOK_SCALAR,
TOK_UNIQUE,
/* Symbols */
TOK_LPAREN,
TOK_RPAREN,
TOK_LBRACKET,
TOK_RBRACKET,
TOK_DASH,
TOK_GT,
TOK_LT,
TOK_COLON,
TOK_DOT,
TOK_LBRACE,
TOK_RBRACE,
TOK_STAR,
TOK_COMMA,
TOK_EQ,
TOK_EQTILDE,
TOK_GTE,
TOK_LTE,
TOK_PIPE,
TOK_DOTDOT,
/* Literals */
TOK_IDENT,
TOK_STRING,
TOK_NUMBER,
/* End of input */
TOK_EOF,
TOK_COUNT_TYPES /* sentinel for array sizing */
} cbm_token_type_t;
typedef struct {
cbm_token_type_t type;
const char *text; /* owned pointer to token text */
int pos; /* byte offset in source */
} cbm_token_t;
/* ── Lexer ──────────────────────────────────────────────────────── */
typedef struct {
cbm_token_t *tokens;
int count;
int capacity;
char *error; /* NULL if no error */
} cbm_lex_result_t;
/* Tokenize a Cypher query string. Caller must call cbm_lex_free(). */
int cbm_lex(const char *input, cbm_lex_result_t *out);
void cbm_lex_free(cbm_lex_result_t *r);
/* ── AST ────────────────────────────────────────────────────────── */
/* Inline property filter {key: "value"} */
typedef struct {
const char *key;
const char *value;
} cbm_prop_filter_t;
/* Node pattern: (variable:Label {props}) */
typedef struct {
const char *variable; /* NULL if anonymous */
const char *label; /* NULL if unlabeled */
cbm_prop_filter_t *props;
int prop_count;
} cbm_node_pattern_t;
/* Relationship pattern: -[:TYPE|TYPE2*min..max]-> */
typedef struct {
const char *variable; /* NULL if anonymous */
const char **types; /* edge type names */
int type_count;
const char *direction; /* "outbound", "inbound", "any" */
int min_hops; /* default 1 */
int max_hops; /* 0 = unbounded */
} cbm_rel_pattern_t;
/* A pattern is alternating nodes and relationships:
* node0 rel0 node1 rel1 node2 ... */
typedef struct {
cbm_node_pattern_t *nodes;
int node_count;
cbm_rel_pattern_t *rels;
int rel_count;
} cbm_pattern_t;
/* WHERE condition */
typedef struct {
const char *variable;
const char *property;
const char *op; /* "=", "<>", "=~", "CONTAINS", "STARTS WITH", "ENDS WITH",
">", "<", ">=", "<=", "IN", "IS NULL", "IS NOT NULL" */
const char *value;
bool negated; /* NOT prefix */
/* coalesce(var.prop, literal) in WHERE (#874): when set, a missing/empty
* property value is substituted with this literal before the op runs. */
const char *coalesce_default;
const char **in_values; /* IN [...] list */
int in_value_count;
/* EXISTS { (var)-[:value]->() } predicate (op=="EXISTS"): `variable` is the
* anchor, `value` the edge type (NULL = any), `exists_dir` the direction
* (0 = outbound, 1 = inbound, 2 = any). */
int exists_dir;
} cbm_condition_t;
/* Expression tree for WHERE clause */
typedef enum {
EXPR_CONDITION, /* leaf: single condition */
EXPR_AND,
EXPR_OR,
EXPR_NOT,
EXPR_XOR
} cbm_expr_type_t;
typedef struct cbm_expr cbm_expr_t;
struct cbm_expr {
cbm_expr_type_t type;
cbm_condition_t cond; /* leaf (EXPR_CONDITION only) */
cbm_expr_t *left; /* AND/OR/XOR left; NOT child */
cbm_expr_t *right; /* AND/OR/XOR right; NULL for NOT */
};
typedef struct {
cbm_expr_t *root; /* expression tree (NULL = use legacy conditions) */
/* Legacy flat model — kept during migration, removed after Phase 2 */
cbm_condition_t *conditions;
int count;
const char *op; /* "AND" or "OR" */
} cbm_where_clause_t;
/* CASE expression: CASE WHEN expr THEN val [ELSE val] END */
typedef struct {
cbm_expr_t *when_expr; /* condition */
const char *then_val; /* result if true */
} cbm_case_branch_t;
typedef struct {
cbm_case_branch_t *branches;
int branch_count;
const char *else_val; /* NULL if no ELSE */
} cbm_case_expr_t;
/* One argument to a multi-argument scalar function (coalesce, substring, ...). */
typedef struct {
const char *variable; /* variable reference (NULL if a literal) */
const char *property; /* property of the variable (NULL if whole var / literal) */
const char *literal; /* literal string/number text (NULL if a variable ref) */
} cbm_func_arg_t;
/* RETURN item */
typedef struct {
const char *variable;
const char *property; /* NULL for whole node */
const char *alias; /* NULL if no alias */
const char *func; /* "COUNT", "SUM", "AVG", "MIN", "MAX", "COLLECT",
"toLower", "toUpper", "toString" or NULL */
bool distinct; /* COUNT(DISTINCT x) — count unique values (#239) */
cbm_case_expr_t *kase; /* CASE expression (NULL if not CASE) */
cbm_func_arg_t *args; /* args for a multi-argument function (NULL if none) */
int arg_count;
} cbm_return_item_t;
typedef struct {
cbm_return_item_t *items;
int count;
bool distinct;
bool star; /* RETURN * */
const char *order_by; /* "variable.property" or "COUNT(var)" or alias */
const char *order_dir; /* "ASC" or "DESC", NULL = default */
int skip; /* SKIP N, 0 = none */
int limit; /* 0 = default */
} cbm_return_clause_t;
/* Full query AST */
typedef struct cbm_query cbm_query_t;
struct cbm_query {
cbm_pattern_t *patterns; /* array of patterns (first = main MATCH) */
int pattern_count;
bool *pattern_optional; /* pattern_optional[i] = true → OPTIONAL MATCH */
cbm_where_clause_t *where; /* NULL if no WHERE */
cbm_return_clause_t *with_clause; /* WITH clause (NULL if none) */
cbm_where_clause_t *post_with_where; /* WHERE after WITH */
cbm_return_clause_t *ret; /* NULL if no RETURN */
cbm_query_t *union_next; /* next query in UNION chain (NULL if none) */
bool union_all; /* true = UNION ALL, false = UNION */
/* UNWIND expr AS var */
const char *unwind_expr; /* expression (literal list or var ref) */
const char *unwind_alias; /* variable name */
};
/* Convenience: access first pattern (backwards compat) */
#define cbm_query_pattern(q) ((q)->patterns[0])
/* ── Parser ─────────────────────────────────────────────────────── */
typedef struct {
cbm_query_t *query;
char *error; /* NULL if no error */
} cbm_parse_result_t;
/* Parse tokens into AST. Caller must call cbm_parse_free(). */
int cbm_parse(const cbm_token_t *tokens, int token_count, cbm_parse_result_t *out);
void cbm_parse_free(cbm_parse_result_t *r);
/* ── Executor ───────────────────────────────────────────────────── */
/* Query result: columns + rows */
typedef struct {
const char **columns;
int col_count;
/* rows[row_idx][col_idx] = string value */
const char ***rows;
int row_count;
/* Non-NULL when the query was rejected (e.g. result too large) */
char *error;
/* Non-NULL advisory (caller-visible, not an error): e.g. a variable-
* length hop range was clamped to the engine ceiling (#797) — without
* this, a clamped expansion is indistinguishable from "no such path". */
char *warning;
} cbm_cypher_result_t;
/* Execute a Cypher query against a store.
* max_rows: limit on output rows (0 = use virtual ceiling of 100k).
* project: project name filter (NULL = all projects).
* Returns -1 on error (check out->error for message). */
int cbm_cypher_execute(cbm_store_t *store, const char *query, const char *project, int max_rows,
cbm_cypher_result_t *out);
/* Free a query result. */
void cbm_cypher_result_free(cbm_cypher_result_t *r);
/* Convenience: lex + parse in one step. */
int cbm_cypher_parse(const char *query, cbm_query_t **out, char **error);
/* Free a query AST. */
void cbm_query_free(cbm_query_t *q);
#endif /* CBM_CYPHER_H */
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
/*
* discover.h — File discovery, language detection, and gitignore matching.
*
* Provides:
* - Language detection from filename/extension (CBM_SZ_64 languages)
* - .m file disambiguation (Objective-C vs Magma vs MATLAB)
* - Gitignore-style pattern parsing and matching
* - Recursive directory walk with hardcoded + gitignore filtering
*
* Depends on: foundation (platform.h for file ops), cbm.h (CBMLanguage enum)
*/
#ifndef CBM_DISCOVER_H
#define CBM_DISCOVER_H
#include <stdbool.h>
#include <stdint.h>
/* Use the existing CBMLanguage enum from extraction layer */
#include "cbm.h"
/* ── Language detection ──────────────────────────────────────────── */
/* Detect language from a filename (basename only, not full path).
* Checks special filenames first (Makefile, CMakeLists.txt, etc.),
* then falls back to extension-based lookup.
* Returns CBM_LANG_COUNT if unknown. */
CBMLanguage cbm_language_for_filename(const char *filename);
/* Detect language from a file extension (including the dot, e.g. ".go").
* Returns CBM_LANG_COUNT if unknown. */
CBMLanguage cbm_language_for_extension(const char *ext);
/* Get the human-readable name for a language enum value.
* Returns "Unknown" for CBM_LANG_COUNT or out-of-range values. */
const char *cbm_language_name(CBMLanguage lang);
/* Disambiguate .m files by reading first 4KB of content.
* Returns CBM_LANG_OBJC, CBM_LANG_MAGMA, or CBM_LANG_MATLAB.
* On read failure, defaults to CBM_LANG_MATLAB. */
CBMLanguage cbm_disambiguate_m(const char *path);
/* ── Gitignore pattern matching ──────────────────────────────────── */
typedef struct cbm_gitignore cbm_gitignore_t;
/* Parse gitignore patterns from a file. Returns NULL on error (file not found, etc.).
* Caller must call cbm_gitignore_free(). */
cbm_gitignore_t *cbm_gitignore_load(const char *path);
/* Parse gitignore patterns from a string (for testing).
* Caller must call cbm_gitignore_free(). */
cbm_gitignore_t *cbm_gitignore_parse(const char *content);
/* Check if a relative path matches any gitignore pattern.
* rel_path should use '/' separators. is_dir indicates if path is a directory. */
bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir);
/* Free a gitignore matcher. NULL-safe. */
void cbm_gitignore_free(cbm_gitignore_t *gi);
/* Append all patterns from src into dst. dst takes ownership of deep copies
* of each src pattern; src is unchanged and must still be freed by the caller.
* NULL-safe on either argument.
* Returns true on success (or when there is nothing to merge). Returns false on
* allocation failure, in which case dst is left exactly as it was (atomic) — no
* partial merge — so a failed merge degrades to "as if src was absent". */
bool cbm_gitignore_merge(cbm_gitignore_t *dst, const cbm_gitignore_t *src);
/* ── Directory skip / suffix filters ─────────────────────────────── */
/* Index mode controls filtering aggressiveness.
* IMPORTANT: these values MUST match pipeline.h exactly. A previous
* mismatch (this header had FAST=1, pipeline.h has FAST=2) caused
* fast-mode filtering to silently no-op depending on include order —
* the pipeline passed value 2, discover.c compared against 1, and no
* files got filtered. */
#ifndef CBM_INDEX_MODE_T_DEFINED
#define CBM_INDEX_MODE_T_DEFINED
typedef enum {
CBM_MODE_FULL = 0, /* parse everything supported */
CBM_MODE_MODERATE = 1, /* aggressive filtering + similarity/semantic edges */
CBM_MODE_FAST = 2, /* aggressive filtering + no similarity/semantic edges */
} cbm_index_mode_t;
#endif
/* Check if a directory name should always be skipped (e.g. .git, node_modules).
* Only checks the basename, not the full path. */
bool cbm_should_skip_dir(const char *dirname, cbm_index_mode_t mode);
/* Check if a file has a suffix that should be skipped (e.g. .pyc, .png). */
bool cbm_has_ignored_suffix(const char *filename, cbm_index_mode_t mode);
/* Check if a specific filename should be skipped in fast mode (e.g. LICENSE, go.sum). */
bool cbm_should_skip_filename(const char *filename, cbm_index_mode_t mode);
/* Check if a path matches fast-mode substring patterns (e.g. .d.ts, .pb.go). */
bool cbm_matches_fast_pattern(const char *filename, cbm_index_mode_t mode);
/* ── File discovery ──────────────────────────────────────────────── */
typedef struct {
char *path; /* absolute path (heap-allocated) */
char *rel_path; /* relative to repo root (heap-allocated) */
CBMLanguage language; /* detected language */
int64_t size; /* file size in bytes */
} cbm_file_info_t;
typedef struct {
cbm_index_mode_t mode; /* CBM_MODE_FULL or CBM_MODE_FAST */
const char *ignore_file; /* path to .cbmignore file, or NULL */
int64_t max_file_size; /* 0 = no limit */
} cbm_discover_opts_t;
/* Walk a repository directory tree and discover all source files.
* Applies hardcoded filters, gitignore patterns, and language detection.
* Returns 0 on success, -1 on error.
* Caller must call cbm_discover_free() on the results. */
int cbm_discover(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count);
/* Like cbm_discover(), but also reports the directory subtrees that were
* skipped during the walk (hardcoded ALWAYS_SKIP/FAST_SKIP dirs + gitignore
* matches), so callers can surface which subtrees were dropped (#411).
* On success, *excluded_out receives a heap-allocated array of strdup'd
* relative directory paths and *excluded_count_out its length; the caller
* owns it and must free via cbm_discover_free_excluded(). Pass NULL for
* excluded_out (and/or excluded_count_out) to discard the list — the internal
* accumulator is freed in that case (no leak).
* Returns 0 on success, -1 on error. */
int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count, char ***excluded_out, int *excluded_count_out);
/* One deliberately-not-indexed file (#963): an individual file dropped by an
* ignore mechanism during the walk (its parent directory was NOT excluded —
* whole subtrees are reported separately as excluded dirs). BY DESIGN, not a
* failure. */
typedef struct {
char *rel_path; /* heap-allocated, relative to repo root */
char *reason; /* heap-allocated: "gitignore" | "cbmignore" |
* "skip-list" | "ignored-suffix" | "fast-pattern" |
* "size-cap" */
} cbm_ignored_file_t;
/* Stored per-file ignore entries are capped (the walk still counts ALL of
* them in *ignored_total_out, so truncation is always explicit, never
* silent). Whole excluded subtrees stay exhaustive via excluded_out. */
enum { CBM_DISCOVER_IGNORED_CAP = 2000 };
/* Like cbm_discover_ex(), but additionally reports the individual files that
* ignore rules dropped (#963 "purposely not indexed"). *ignored_out receives
* a heap array (caller frees via cbm_discover_free_ignored),
* *ignored_count_out its stored length (<= CBM_DISCOVER_IGNORED_CAP), and
* *ignored_total_out the TOTAL number of ignored files seen. Pass NULL to
* skip the collection entirely. */
int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out,
int *count, char ***excluded_out, int *excluded_count_out,
cbm_ignored_file_t **ignored_out, int *ignored_count_out,
int *ignored_total_out);
/* Free an array of file info results. NULL-safe. */
void cbm_discover_free(cbm_file_info_t *files, int count);
/* Free the excluded-directory list returned by cbm_discover_ex(). NULL-safe. */
void cbm_discover_free_excluded(char **excluded, int count);
/* Free the ignored-file list returned by cbm_discover_ex2(). NULL-safe. */
void cbm_discover_free_ignored(cbm_ignored_file_t *ignored, int count);
#endif /* CBM_DISCOVER_H */
+430
View File
@@ -0,0 +1,430 @@
/*
* gitignore.c — Gitignore-style pattern matching.
*
* Implements the core gitignore pattern matching algorithm:
* - * matches anything except /
* - ** matches any number of path components
* - ? matches any single character except /
* - [abc] and [a-z] character classes
* - ! prefix for negation
* - trailing / for directory-only matching
* - patterns with / are rooted (anchored to base)
*/
#include "foundation/constants.h"
#include "foundation/compat_fs.h"
enum { GI_INIT_CAP = 16, GI_CHAR_IDX1 = 1, GI_CHAR_IDX2 = 2, GI_SKIP3 = 3 };
#include "discover/discover.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Pattern representation ──────────────────────────────────────── */
typedef struct {
char *pattern; /* the glob pattern (normalized) */
bool negated; /* starts with ! */
bool dir_only; /* ends with / */
bool rooted; /* contains / (anchored to root) */
} gi_pattern_t;
struct cbm_gitignore {
gi_pattern_t *patterns;
int count;
int capacity;
};
/* ── Pattern matching engine ─────────────────────────────────────── */
/* Forward declaration for recursive calls. */
static bool glob_match(const char *pat, const char *str); // NOLINT(misc-no-recursion)
/* Match a ** (doublestar-slash) pattern: try rest at every / boundary. */
static bool glob_match_doublestar_slash(const char *pat, // NOLINT(misc-no-recursion)
const char *str) {
if (glob_match(pat, str)) {
return true;
}
for (const char *s = str; *s; s++) {
if (*s == '/' && glob_match(pat, s + SKIP_ONE)) {
return true;
}
}
return false;
}
/* Match a ** (doublestar) followed by non-slash: try at every position. */
static bool glob_match_doublestar_any(const char *pat, // NOLINT(misc-no-recursion)
const char *str) {
for (const char *s = str;; s++) {
if (glob_match(pat, s)) {
return true;
}
if (!*s) {
return false;
}
}
}
/* Match a * (single star): match any sequence not containing /. */
static bool glob_match_star(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
for (const char *s = str;; s++) {
if (glob_match(pat, s)) {
return true;
}
if (!*s || *s == '/') {
return false;
}
}
}
/* Match a [...] character class at current position.
* Returns true if matched. Advances *pat_out past the closing ']'. */
static bool glob_match_charclass(const char *pat, char ch, const char **pat_out) {
bool negate_class = false;
if (*pat == '!' || *pat == '^') {
negate_class = true;
pat++;
}
bool matched = false;
char prev = 0;
while (*pat && *pat != ']') {
if (*pat == '-' && prev && pat[GI_CHAR_IDX1] && pat[GI_CHAR_IDX1] != ']') {
pat++;
if (ch >= prev && ch <= *pat) {
matched = true;
}
prev = *pat;
pat++;
} else {
if (ch == *pat) {
matched = true;
}
prev = *pat;
pat++;
}
}
if (*pat == ']') {
pat++;
}
*pat_out = pat;
return negate_class ? !matched : matched;
}
/*
* Match a glob pattern against a string.
* Handles: * (non-slash), ** (any path), ? (single non-slash), [class]
*/
/* Handle ** at current position. Returns match result. */
static bool glob_match_doublestar(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
if (pat[GI_CHAR_IDX2] == '/') {
return glob_match_doublestar_slash(pat + GI_SKIP3, str);
}
if (pat[GI_CHAR_IDX2] == '\0') {
return true;
}
return glob_match_doublestar_any(pat + GI_CHAR_IDX2, str);
}
static bool glob_match(const char *pat, const char *str) { // NOLINT(misc-no-recursion)
while (*pat && *str) {
if (pat[0] == '*' && pat[GI_CHAR_IDX1] == '*') {
return glob_match_doublestar(pat, str);
}
if (*pat == '*') {
return glob_match_star(pat + SKIP_ONE, str);
}
if (*pat == '?') {
if (*str == '/') {
return false;
}
pat++;
str++;
continue;
}
if (*pat == '[') {
const char *new_pat = NULL;
if (!glob_match_charclass(pat + SKIP_ONE, *str, &new_pat)) {
return false;
}
pat = new_pat;
str++;
continue;
}
if (*pat != *str) {
return false;
}
pat++;
str++;
}
while (*pat == '*') {
pat++;
}
return *pat == '\0' && *str == '\0';
}
/* ── Pattern parsing ─────────────────────────────────────────────── */
static void gi_add_pattern(cbm_gitignore_t *gi, const char *line, int len) {
/* Trim trailing whitespace */
while (len > 0 && (line[len - SKIP_ONE] == ' ' || line[len - SKIP_ONE] == '\t' ||
line[len - SKIP_ONE] == '\r')) {
len--;
}
if (len == 0) {
return;
}
gi_pattern_t p = {0};
/* Check for negation */
const char *start = line;
if (*start == '!') {
p.negated = true;
start++;
len--;
}
if (len == 0) {
return;
}
/* Check for trailing / (directory-only) */
if (start[len - SKIP_ONE] == '/') {
p.dir_only = true;
len--;
}
if (len == 0) {
return;
}
/* Check for leading / (rooted) */
if (*start == '/') {
p.rooted = true;
start++;
len--;
}
if (len == 0) {
return;
}
/* Check if pattern contains / anywhere (makes it rooted) */
if (!p.rooted) {
for (int i = 0; i < len; i++) {
if (start[i] == '/') {
p.rooted = true;
break;
}
}
}
/* Copy pattern */
p.pattern = malloc(len + SKIP_ONE);
if (!p.pattern) {
return;
}
memcpy(p.pattern, start, len);
p.pattern[len] = '\0';
/* Grow array if needed */
if (gi->count >= gi->capacity) {
int new_cap = gi->capacity ? gi->capacity * PAIR_LEN : GI_INIT_CAP;
gi_pattern_t *new_patterns = realloc(gi->patterns, new_cap * sizeof(gi_pattern_t));
if (!new_patterns) {
free(p.pattern);
return;
}
gi->patterns = new_patterns;
gi->capacity = new_cap;
}
gi->patterns[gi->count++] = p;
}
/* ── Public API ──────────────────────────────────────────────────── */
cbm_gitignore_t *cbm_gitignore_parse(const char *content) {
if (!content) {
return NULL;
}
cbm_gitignore_t *gi = calloc(CBM_ALLOC_ONE, sizeof(cbm_gitignore_t));
if (!gi) {
return NULL;
}
const char *line = content;
while (*line) {
/* Find end of line */
const char *eol = strchr(line, '\n');
int len = eol ? (int)(eol - line) : (int)strlen(line);
/* Skip comments and blank lines */
if (len > 0 && line[0] != '#') {
gi_add_pattern(gi, line, len);
}
if (!eol) {
break;
}
line = eol + SKIP_ONE;
}
return gi;
}
cbm_gitignore_t *cbm_gitignore_load(const char *path) {
if (!path) {
return NULL;
}
FILE *f = cbm_fopen(path, "r");
if (!f) {
return NULL;
}
/* Read entire file */
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0) {
(void)fclose(f);
return cbm_gitignore_parse("");
}
char *buf = malloc(size + SKIP_ONE);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t n = fread(buf, SKIP_ONE, size, f);
buf[n] = '\0';
(void)fclose(f);
cbm_gitignore_t *gi = cbm_gitignore_parse(buf);
free(buf);
return gi;
}
/* Match a non-rooted pattern against basename and path suffixes. */
static bool match_unrooted(const char *pattern, const char *rel_path, const char *basename) {
if (glob_match(pattern, basename)) {
return true;
}
if (!strchr(rel_path, '/')) {
return false;
}
/* Try matching at every / boundary */
const char *s = rel_path;
while (*s) {
if (glob_match(pattern, s)) {
return true;
}
const char *next = strchr(s, '/');
if (!next) {
break;
}
s = next + SKIP_ONE;
}
return false;
}
int cbm_gitignore_match_result(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
if (!gi || !rel_path) {
return 0;
}
/* Extract the basename for non-rooted pattern matching */
const char *basename = strrchr(rel_path, '/');
basename = basename ? basename + SKIP_ONE : rel_path;
int matched = 0;
for (int i = 0; i < gi->count; i++) {
const gi_pattern_t *p = &gi->patterns[i];
if (p->dir_only && !is_dir) {
continue;
}
bool this_match = p->rooted ? glob_match(p->pattern, rel_path)
: match_unrooted(p->pattern, rel_path, basename);
if (this_match) {
matched = p->negated ? -1 : 1;
}
}
return matched;
}
bool cbm_gitignore_matches(const cbm_gitignore_t *gi, const char *rel_path, bool is_dir) {
return cbm_gitignore_match_result(gi, rel_path, is_dir) > 0;
}
void cbm_gitignore_free(cbm_gitignore_t *gi) {
if (!gi) {
return;
}
for (int i = 0; i < gi->count; i++) {
free(gi->patterns[i].pattern);
}
free(gi->patterns);
free(gi);
}
/* Test seam: lets a unit test simulate strdup() failure mid-merge so the
* atomic-rollback path can be exercised without real OOM. NULL = use strdup. */
char *(*cbm_gitignore_merge_dup_hook_for_test)(const char *) = NULL;
bool cbm_gitignore_merge(cbm_gitignore_t *dst, const cbm_gitignore_t *src) {
if (!dst) {
return false;
}
if (!src || src->count == 0) {
return true; /* nothing to merge */
}
int needed = dst->count + src->count;
if (needed > dst->capacity) {
gi_pattern_t *grown = realloc(dst->patterns, (size_t)needed * sizeof(gi_pattern_t));
if (!grown) {
return false; /* dst left unchanged */
}
dst->patterns = grown;
dst->capacity = needed;
}
int start_count = dst->count;
for (int i = 0; i < src->count; i++) {
char *pat = cbm_gitignore_merge_dup_hook_for_test
? cbm_gitignore_merge_dup_hook_for_test(src->patterns[i].pattern)
: strdup(src->patterns[i].pattern);
if (!pat) {
/* Roll back partial copies so dst is unchanged on failure (atomic
* merge). A silent partial merge could drop the very exclude
* pattern the caller relied on while keeping others. */
for (int j = start_count; j < dst->count; j++) {
free(dst->patterns[j].pattern);
}
dst->count = start_count;
return false;
}
dst->patterns[dst->count].pattern = pat;
dst->patterns[dst->count].negated = src->patterns[i].negated;
dst->patterns[dst->count].dir_only = src->patterns[i].dir_only;
dst->patterns[dst->count].rooted = src->patterns[i].rooted;
dst->count++;
}
return true;
}
File diff suppressed because it is too large Load Diff
+389
View File
@@ -0,0 +1,389 @@
/*
* userconfig.c — User-defined extension→language mappings.
*
* Reads extra_extensions from:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*/
#include "discover/userconfig.h"
#include "cbm.h" /* CBMLanguage, CBM_LANG_* */
#include "foundation/constants.h"
#include "foundation/platform.h" /* cbm_safe_getenv */
#include "foundation/compat_fs.h"
enum { MAX_CONFIG_SIZE = 65536 };
#include "foundation/log.h"
#include <yyjson/yyjson.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Process-global user config pointer ──────────────────────────── */
static const cbm_userconfig_t *g_userconfig = NULL;
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg) {
g_userconfig = cfg;
}
const cbm_userconfig_t *cbm_get_user_lang_config(void) {
return g_userconfig;
}
/* ── Language name → enum table ──────────────────────────────────── */
/*
* Reverse-mapping from lowercase language name strings to CBMLanguage.
* Covers all names exposed by cbm_language_name() plus common aliases.
*/
typedef struct {
const char *name; /* lowercase */
CBMLanguage lang;
} lang_name_entry_t;
static const lang_name_entry_t LANG_NAME_TABLE[] = {
{"go", CBM_LANG_GO},
{"python", CBM_LANG_PYTHON},
{"javascript", CBM_LANG_JAVASCRIPT},
{"typescript", CBM_LANG_TYPESCRIPT},
{"tsx", CBM_LANG_TSX},
{"rust", CBM_LANG_RUST},
{"java", CBM_LANG_JAVA},
{"c++", CBM_LANG_CPP},
{"cpp", CBM_LANG_CPP},
{"c#", CBM_LANG_CSHARP},
{"csharp", CBM_LANG_CSHARP},
{"php", CBM_LANG_PHP},
{"lua", CBM_LANG_LUA},
{"scala", CBM_LANG_SCALA},
{"kotlin", CBM_LANG_KOTLIN},
{"ruby", CBM_LANG_RUBY},
{"c", CBM_LANG_C},
{"bash", CBM_LANG_BASH},
{"sh", CBM_LANG_BASH},
{"zig", CBM_LANG_ZIG},
{"elixir", CBM_LANG_ELIXIR},
{"haskell", CBM_LANG_HASKELL},
{"ocaml", CBM_LANG_OCAML},
{"objective-c", CBM_LANG_OBJC},
{"objc", CBM_LANG_OBJC},
{"swift", CBM_LANG_SWIFT},
{"dart", CBM_LANG_DART},
{"perl", CBM_LANG_PERL},
{"groovy", CBM_LANG_GROOVY},
{"erlang", CBM_LANG_ERLANG},
{"r", CBM_LANG_R},
{"html", CBM_LANG_HTML},
{"css", CBM_LANG_CSS},
{"scss", CBM_LANG_SCSS},
{"yaml", CBM_LANG_YAML},
{"toml", CBM_LANG_TOML},
{"hcl", CBM_LANG_HCL},
{"terraform", CBM_LANG_HCL},
{"sql", CBM_LANG_SQL},
{"dockerfile", CBM_LANG_DOCKERFILE},
{"clojure", CBM_LANG_CLOJURE},
{"f#", CBM_LANG_FSHARP},
{"fsharp", CBM_LANG_FSHARP},
{"julia", CBM_LANG_JULIA},
{"vimscript", CBM_LANG_VIMSCRIPT},
{"nix", CBM_LANG_NIX},
{"common lisp", CBM_LANG_COMMONLISP},
{"commonlisp", CBM_LANG_COMMONLISP},
{"lisp", CBM_LANG_COMMONLISP},
{"elm", CBM_LANG_ELM},
{"fortran", CBM_LANG_FORTRAN},
{"cuda", CBM_LANG_CUDA},
{"cobol", CBM_LANG_COBOL},
{"verilog", CBM_LANG_VERILOG},
{"emacs lisp", CBM_LANG_EMACSLISP},
{"emacslisp", CBM_LANG_EMACSLISP},
{"json", CBM_LANG_JSON},
{"xml", CBM_LANG_XML},
{"markdown", CBM_LANG_MARKDOWN},
{"makefile", CBM_LANG_MAKEFILE},
{"cmake", CBM_LANG_CMAKE},
{"protobuf", CBM_LANG_PROTOBUF},
{"graphql", CBM_LANG_GRAPHQL},
{"vue", CBM_LANG_VUE},
{"svelte", CBM_LANG_SVELTE},
{"meson", CBM_LANG_MESON},
{"glsl", CBM_LANG_GLSL},
{"ini", CBM_LANG_INI},
{"matlab", CBM_LANG_MATLAB},
{"mojo", CBM_LANG_MOJO},
{"lean", CBM_LANG_LEAN},
{"form", CBM_LANG_FORM},
{"magma", CBM_LANG_MAGMA},
{"wolfram", CBM_LANG_WOLFRAM},
};
#define LANG_NAME_TABLE_SIZE (sizeof(LANG_NAME_TABLE) / sizeof(LANG_NAME_TABLE[0]))
/*
* Parse a language string (case-insensitive) to a CBMLanguage enum.
* Returns CBM_LANG_COUNT if the string is not recognized.
*/
static CBMLanguage lang_from_string(const char *s) {
if (!s || !s[0]) {
return CBM_LANG_COUNT;
}
/* Build a lowercase copy for comparison */
char lower[CBM_SZ_64];
size_t i;
for (i = 0; i < sizeof(lower) - SKIP_ONE && s[i]; i++) {
lower[i] = (char)tolower((unsigned char)s[i]);
}
lower[i] = '\0';
for (size_t j = 0; j < LANG_NAME_TABLE_SIZE; j++) {
if (strcmp(LANG_NAME_TABLE[j].name, lower) == 0) {
return LANG_NAME_TABLE[j].lang;
}
}
return CBM_LANG_COUNT;
}
/* ── Config directory helper ─────────────────────────────────────── */
/* cbm_app_config_dir() is now in platform.c (cross-platform). */
/* ── JSON parsing ────────────────────────────────────────────────── */
/*
* Parse extra_extensions from a yyjson object root.
* Appends valid entries to *entries / *count (growing via realloc).
* Project-level entries (from_project=true) are appended after global
* entries so that a later dedup pass can prefer project values.
*
* Returns 0 on success, -1 on alloc failure.
*/
static int parse_extra_extensions(yyjson_val *root, cbm_userext_t **entries, int *count,
const char *source_label) {
if (!yyjson_is_obj(root)) {
cbm_log_warn("userconfig.bad_root", "file", source_label);
return 0;
}
yyjson_val *extra = yyjson_obj_get(root, "extra_extensions");
if (!extra) {
return 0; /* key absent — fine */
}
if (!yyjson_is_obj(extra)) {
cbm_log_warn("userconfig.bad_extra_extensions", "file", source_label);
return 0;
}
yyjson_obj_iter iter;
yyjson_obj_iter_init(extra, &iter);
yyjson_val *key;
while ((key = yyjson_obj_iter_next(&iter)) != NULL) {
yyjson_val *val = yyjson_obj_iter_get_val(key);
const char *ext_str = yyjson_get_str(key);
const char *lang_str = yyjson_get_str(val);
if (!ext_str || !lang_str) {
cbm_log_warn("userconfig.skip_non_string", "file", source_label);
continue;
}
/* Extension must start with '.' */
if (ext_str[0] != '.') {
cbm_log_warn("userconfig.skip_bad_ext", "file", source_label, "ext", ext_str);
continue;
}
CBMLanguage lang = lang_from_string(lang_str);
if (lang == CBM_LANG_COUNT) {
cbm_log_warn("userconfig.unknown_lang", "file", source_label, "lang", lang_str);
continue; /* fail-open: skip unknown languages */
}
/* Grow the array */
cbm_userext_t *tmp = realloc(*entries, (size_t)(*count + SKIP_ONE) * sizeof(cbm_userext_t));
if (!tmp) {
return CBM_NOT_FOUND;
}
*entries = tmp;
char *ext_copy = strdup(ext_str);
if (!ext_copy) {
return CBM_NOT_FOUND;
}
(*entries)[*count].ext = ext_copy;
(*entries)[*count].lang = lang;
(*count)++;
}
return 0;
}
/*
* Read a JSON file and parse extra_extensions from it.
* Silently ignores missing files. Logs warnings for corrupt JSON.
* Returns 0 on success (or absent file), -1 on alloc failure.
*/
static int load_config_file(const char *path, cbm_userext_t **entries, int *count) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return 0; /* file absent — silently ignore */
}
if (fseek(f, 0, SEEK_END) != 0) {
(void)fclose(f);
return 0;
}
long len = ftell(f);
if (fseek(f, 0, SEEK_SET) != 0) {
(void)fclose(f);
return 0;
}
if (len <= 0 || len > MAX_CONFIG_SIZE) {
(void)fclose(f);
if (len > MAX_CONFIG_SIZE) {
cbm_log_warn("userconfig.file_too_large", "path", path);
}
return 0;
}
char *buf = malloc((size_t)len + SKIP_ONE);
if (!buf) {
(void)fclose(f);
return CBM_NOT_FOUND;
}
size_t nread = fread(buf, SKIP_ONE, (size_t)len, f);
(void)fclose(f);
if (nread > (size_t)len) {
nread = (size_t)len;
}
buf[nread] = '\0';
yyjson_doc *doc = yyjson_read(buf, nread, 0);
free(buf);
if (!doc) {
cbm_log_warn("userconfig.corrupt_json", "path", path);
return 0; /* corrupt JSON — silently ignore (fail-open) */
}
yyjson_val *root = yyjson_doc_get_root(doc);
int rc = parse_extra_extensions(root, entries, count, path);
yyjson_doc_free(doc);
return rc;
}
/* ── Public API ──────────────────────────────────────────────────── */
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path) {
cbm_userconfig_t *cfg = calloc(CBM_ALLOC_ONE, sizeof(cbm_userconfig_t));
if (!cfg) {
return NULL;
}
cbm_userext_t *entries = NULL;
int count = 0;
/* ── Step 1: Load global config ── */
enum { PATH_BUF_SZ = 1280 };
const char *cfg_base = cbm_app_config_dir();
const char *cfg_fallback = cfg_base ? cfg_base : "/tmp";
char global_path[PATH_BUF_SZ];
snprintf(global_path, sizeof(global_path), "%s/codebase-memory-mcp/config.json", cfg_fallback);
if (load_config_file(global_path, &entries, &count) != 0) {
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
int global_count = count; /* entries[0..global_count) are from global */
/* ── Step 2: Load project config ── */
if (repo_path && repo_path[0]) {
char project_path[PATH_BUF_SZ];
snprintf(project_path, sizeof(project_path), "%s/.codebase-memory.json", repo_path);
if (load_config_file(project_path, &entries, &count) != 0) {
/* Free already-allocated entries */
for (int i = 0; i < count; i++) {
free(entries[i].ext);
}
free(entries);
free(cfg);
return NULL;
}
}
/*
* ── Step 3: Dedup — project entries win over global ──
*
* For any extension that appears in both global (indices 0..global_count)
* and project (indices global_count..count), remove the global entry by
* replacing it with the last global entry (order-insensitive dedup).
*/
for (int p = global_count; p < count; p++) {
for (int g = 0; g < global_count; g++) {
if (entries[g].ext && strcmp(entries[g].ext, entries[p].ext) == 0) {
/* Remove global entry: overwrite with last global entry */
free(entries[g].ext);
entries[g] = entries[global_count - SKIP_ONE];
entries[global_count - SKIP_ONE].ext = NULL; /* mark as consumed */
global_count--;
break;
}
}
}
/*
* Compact: remove any NULL-ext slots left by the dedup step.
* (Those are the consumed "last global" entries.)
*/
int write_idx = 0;
for (int i = 0; i < count; i++) {
if (entries[i].ext != NULL) {
entries[write_idx++] = entries[i];
}
}
count = write_idx;
cfg->entries = entries;
cfg->count = count;
return cfg;
}
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext) {
if (!cfg || !ext || !ext[0]) {
return CBM_LANG_COUNT;
}
for (int i = 0; i < cfg->count; i++) {
if (cfg->entries[i].ext && strcmp(cfg->entries[i].ext, ext) == 0) {
return cfg->entries[i].lang;
}
}
return CBM_LANG_COUNT;
}
void cbm_userconfig_free(cbm_userconfig_t *cfg) {
if (!cfg) {
return;
}
for (int i = 0; i < cfg->count; i++) {
free(cfg->entries[i].ext);
}
free(cfg->entries);
free(cfg);
}
+72
View File
@@ -0,0 +1,72 @@
/*
* userconfig.h — User-defined file extension → language mappings.
*
* Reads extra_extensions from two optional JSON config files:
* Global: $XDG_CONFIG_HOME/codebase-memory-mcp/config.json
* (falls back to ~/.config/codebase-memory-mcp/config.json)
* Project: {repo_root}/.codebase-memory.json
*
* Project config wins over global. Unknown language values warn and are
* skipped (fail-open). Missing files are silently ignored.
*
* Format:
* {"extra_extensions": {".blade.php": "php", ".mjs": "javascript"}}
*
* The language string matching is case-insensitive.
*/
#ifndef CBM_USERCONFIG_H
#define CBM_USERCONFIG_H
#include "cbm.h" /* CBMLanguage */
/* ── Types ──────────────────────────────────────────────────────── */
typedef struct {
char *ext; /* file extension including dot, e.g. ".blade.php" */
CBMLanguage lang; /* resolved language enum */
} cbm_userext_t;
typedef struct {
cbm_userext_t *entries; /* heap-allocated array */
int count; /* number of entries */
} cbm_userconfig_t;
/* ── API ────────────────────────────────────────────────────────── */
/*
* Load user config from global + project files, merge (project wins).
* repo_path: absolute path to the repository root (for project config).
* Returns a heap-allocated cbm_userconfig_t (caller must free via
* cbm_userconfig_free). Returns NULL only on allocation failure.
* Missing config files are silently ignored.
*/
cbm_userconfig_t *cbm_userconfig_load(const char *repo_path);
/*
* Look up a file extension in the user config.
* ext: extension including dot, e.g. ".blade.php"
* Returns the mapped CBMLanguage, or CBM_LANG_COUNT if not found.
*/
CBMLanguage cbm_userconfig_lookup(const cbm_userconfig_t *cfg, const char *ext);
/* Free a cbm_userconfig_t returned by cbm_userconfig_load. NULL-safe. */
void cbm_userconfig_free(cbm_userconfig_t *cfg);
/* ── Integration hook ───────────────────────────────────────────── */
/*
* Set the process-global user config that cbm_language_for_extension()
* will consult before the built-in table.
* cfg may be NULL to clear the override.
* Not thread-safe — call before spawning worker threads.
*/
void cbm_set_user_lang_config(const cbm_userconfig_t *cfg);
/*
* Get the currently active process-global user config.
* Returns NULL if none has been set.
* Called internally by cbm_language_for_extension().
*/
const cbm_userconfig_t *cbm_get_user_lang_config(void);
#endif /* CBM_USERCONFIG_H */
+158
View File
@@ -0,0 +1,158 @@
/*
* arena.c — Bump allocator implementation.
*
* Restructured from internal/cbm/arena.c with additions:
* - cbm_arena_init_sized() for custom block sizes
* - cbm_arena_calloc() for zero-initialized allocations
* - cbm_arena_reset() for reuse without full destroy
* - cbm_arena_total() for allocation tracking
*
* Arena blocks use malloc/free (= mimalloc in production builds).
*/
#include "arena.h"
#include "foundation/constants.h"
enum { ARENA_ALIGN = 7, ARENA_GROW_OK = 1 };
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <stdio.h>
void cbm_arena_init(CBMArena *a) {
cbm_arena_init_sized(a, CBM_ARENA_DEFAULT_BLOCK_SIZE);
}
void cbm_arena_init_sized(CBMArena *a, size_t block_size) {
memset(a, 0, sizeof(*a));
if (block_size < CBM_SZ_64) {
block_size = CBM_SZ_64; /* minimum sanity */
}
a->block_size = block_size;
a->blocks[0] = (char *)malloc(block_size);
if (a->blocks[0]) {
a->block_sizes[0] = block_size;
a->nblocks = SKIP_ONE;
}
}
static int arena_grow(CBMArena *a, size_t min_size) {
if (a->nblocks >= CBM_ARENA_MAX_BLOCKS) {
return 0;
}
size_t new_size = a->block_size * PAIR_LEN;
if (new_size < min_size) {
new_size = min_size;
}
char *block = (char *)malloc(new_size);
if (!block) {
return 0;
}
a->blocks[a->nblocks] = block;
a->block_sizes[a->nblocks] = new_size;
a->nblocks++;
a->block_size = new_size;
a->used = 0;
return ARENA_GROW_OK;
}
void *cbm_arena_alloc(CBMArena *a, size_t n) {
if (!a || n == 0) {
return NULL;
}
/* 8-byte alignment */
n = (n + ARENA_ALIGN) & ~(size_t)ARENA_ALIGN;
if (a->nblocks == 0) {
return NULL;
}
if (a->used + n > a->block_size) {
if (!arena_grow(a, n)) {
return NULL;
}
}
char *ptr = a->blocks[a->nblocks - SKIP_ONE] + a->used;
a->used += n;
a->total_alloc += n;
return ptr;
}
void *cbm_arena_calloc(CBMArena *a, size_t n) {
void *p = cbm_arena_alloc(a, n);
if (p) {
memset(p, 0, n);
}
return p;
}
char *cbm_arena_strdup(CBMArena *a, const char *s) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *dst = (char *)cbm_arena_alloc(a, len + SKIP_ONE);
if (dst) {
memcpy(dst, s, len + SKIP_ONE);
}
return dst;
}
char *cbm_arena_strndup(CBMArena *a, const char *s, size_t len) {
if (!s) {
return NULL;
}
char *dst = (char *)cbm_arena_alloc(a, len + SKIP_ONE);
if (dst) {
memcpy(dst, s, len);
dst[len] = '\0';
}
return dst;
}
char *cbm_arena_sprintf(CBMArena *a, const char *fmt, ...) {
va_list args;
va_start(args, fmt);
int needed = vsnprintf(NULL, 0, fmt, args);
va_end(args);
if (needed < 0) {
return NULL;
}
char *dst = (char *)cbm_arena_alloc(a, (size_t)needed + SKIP_ONE);
if (!dst) {
return NULL;
}
va_start(args, fmt);
vsnprintf(dst, (size_t)needed + SKIP_ONE, fmt, args);
va_end(args);
return dst;
}
void cbm_arena_reset(CBMArena *a) {
/* Keep first block, free the rest */
for (int i = SKIP_ONE; i < a->nblocks; i++) {
free(a->blocks[i]);
a->blocks[i] = NULL;
a->block_sizes[i] = 0;
}
if (a->nblocks > SKIP_ONE) {
a->nblocks = SKIP_ONE;
}
a->used = 0;
a->total_alloc = 0;
/* Reset block_size to match surviving block — prevents overflow if
* block_size grew during previous allocations (e.g., CBM_SZ_128 → CBM_SZ_256). */
if (a->nblocks == SKIP_ONE) {
a->block_size = a->block_sizes[0];
}
}
void cbm_arena_destroy(CBMArena *a) {
for (int i = 0; i < a->nblocks; i++) {
free(a->blocks[i]);
}
memset(a, 0, sizeof(*a));
}
size_t cbm_arena_total(const CBMArena *a) {
return a->total_alloc;
}
+59
View File
@@ -0,0 +1,59 @@
/*
* arena.h — Bump allocator with block-based growth.
*
* All memory is freed at once via cbm_arena_destroy(). Individual frees are
* not supported — this is by design for per-file extraction where all data
* has the same lifetime.
*
* Restructured from internal/cbm/arena.h for the pure C rewrite.
* New additions: cbm_arena_reset() for reuse without realloc.
*/
#ifndef CBM_ARENA_H
#define CBM_ARENA_H
#include <stddef.h>
#include <stdarg.h>
#define CBM_ARENA_MAX_BLOCKS 256
#define CBM_ARENA_DEFAULT_BLOCK_SIZE ((size_t)64 * 1024) /* 64KB */
typedef struct {
char *blocks[CBM_ARENA_MAX_BLOCKS];
size_t block_sizes[CBM_ARENA_MAX_BLOCKS]; /* per-block sizes (for stats) */
int nblocks;
size_t block_size; /* current block capacity */
size_t used; /* bytes used in current block */
size_t total_alloc; /* cumulative bytes allocated (for stats) */
} CBMArena;
/* Initialize arena with default block size. */
void cbm_arena_init(CBMArena *a);
/* Initialize arena with a custom initial block size. */
void cbm_arena_init_sized(CBMArena *a, size_t block_size);
/* Allocate n bytes (8-byte aligned). Returns NULL on OOM. */
void *cbm_arena_alloc(CBMArena *a, size_t n);
/* Allocate n bytes, zero-initialized. */
void *cbm_arena_calloc(CBMArena *a, size_t n);
/* Duplicate a NUL-terminated string. */
char *cbm_arena_strdup(CBMArena *a, const char *s);
/* Duplicate a string of known length, NUL-terminate. */
char *cbm_arena_strndup(CBMArena *a, const char *s, size_t len);
/* sprintf into arena memory. */
char *cbm_arena_sprintf(CBMArena *a, const char *fmt, ...) __attribute__((format(printf, 2, 3)));
/* Reset arena for reuse: keeps first block, frees the rest. */
void cbm_arena_reset(CBMArena *a);
/* Free all blocks. Arena is zeroed after this. */
void cbm_arena_destroy(CBMArena *a);
/* Return total bytes allocated (for diagnostics). */
size_t cbm_arena_total(const CBMArena *a);
#endif /* CBM_ARENA_H */
+165
View File
@@ -0,0 +1,165 @@
/*
* compat.c — Implementations for Windows-only shims.
*
* On POSIX, these functions are provided by the standard library via
* macros in compat.h. On Windows, we implement them here.
*/
#include "foundation/compat.h"
#include "foundation/constants.h"
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <io.h>
#include <fcntl.h>
#include <sys/stat.h>
#endif
/* ── strndup (Windows lacks it) ───────────────────────────────── */
#ifdef _WIN32
char *cbm_strndup(const char *s, size_t n) {
if (!s) {
return NULL;
}
size_t len = 0;
while (len < n && s[len]) {
len++;
}
char *d = (char *)malloc(len + SKIP_ONE);
if (d) {
memcpy(d, s, len);
d[len] = '\0';
}
return d;
}
#endif
/* ── strcasestr (Windows lacks it) ────────────────────────────── */
#ifdef _WIN32
char *cbm_strcasestr(const char *haystack, const char *needle) {
if (!needle[0])
return (char *)haystack;
size_t nlen = strlen(needle);
for (; *haystack; haystack++) {
if (_strnicmp(haystack, needle, nlen) == 0)
return (char *)haystack;
}
return NULL;
}
#endif
/* ── mkdtemp (Windows lacks it) ───────────────────────────────── */
#ifdef _WIN32
#include <direct.h>
char *cbm_mkdtemp(char *tmpl) {
/* Build path in static buffer, then copy back to caller.
* Callers must provide buffers >= CBM_SZ_256 bytes (all test code does). */
static char buf[CBM_SZ_512];
if (strncmp(tmpl, "/tmp/", 5) == 0) {
const char *tmp = getenv("TEMP");
if (!tmp)
tmp = getenv("TMP");
if (!tmp)
tmp = ".";
snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5);
} else {
snprintf(buf, sizeof(buf), "%s", tmpl);
}
if (!_mktemp(buf))
return NULL;
if (_mkdir(buf) != 0)
return NULL;
/* Normalize to forward slashes. Callers embed this path in JSON repo_path
* (where "\t"/"\a" are invalid escapes → index fails) and pass it to git -C.
* Windows file APIs accept forward slashes, so the created dir is unaffected. */
for (char *p = buf; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
/* Copy result back — callers now use char[CBM_SZ_256]+ buffers */
strcpy(tmpl, buf);
return tmpl;
}
#endif
/* ── mkstemp (Windows lacks it) ───────────────────────────────── */
#ifdef _WIN32
int cbm_mkstemp(char *tmpl) {
/* Rewrite /tmp/ to %TEMP%\ like cbm_mkdtemp */
static char buf[CBM_SZ_512];
if (strncmp(tmpl, "/tmp/", 5) == 0) {
const char *tmp = getenv("TEMP");
if (!tmp)
tmp = getenv("TMP");
if (!tmp)
tmp = ".";
snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5);
} else {
snprintf(buf, sizeof(buf), "%s", tmpl);
}
if (!_mktemp(buf))
return CBM_NOT_FOUND;
int fd = _open(buf, _O_CREAT | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE);
if (fd >= 0)
strcpy(tmpl, buf);
return fd;
}
#endif
/* ── clock_gettime (Windows lacks it) ─────────────────────────── */
#ifdef _WIN32
int cbm_clock_gettime(int clk_id, struct timespec *tp) {
(void)clk_id;
LARGE_INTEGER freq, count;
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&count);
tp->tv_sec = (time_t)(count.QuadPart / freq.QuadPart);
tp->tv_nsec = (long)((count.QuadPart % freq.QuadPart) * 1000000000LL / freq.QuadPart);
return 0;
}
#endif
/* ── getline (Windows lacks it) ───────────────────────────────── */
#ifdef _WIN32
ssize_t cbm_getline(char **lineptr, size_t *n, FILE *stream) {
if (!lineptr || !n || !stream) {
return CBM_NOT_FOUND;
}
if (!*lineptr || *n == 0) {
*n = CBM_SZ_128;
*lineptr = (char *)malloc(*n);
if (!*lineptr) {
return CBM_NOT_FOUND;
}
}
size_t pos = 0;
int c;
while ((c = fgetc(stream)) != EOF) {
if (pos + 1 >= *n) {
size_t new_n = *n * PAIR_LEN;
char *tmp = (char *)realloc(*lineptr, new_n);
if (!tmp) {
return CBM_NOT_FOUND;
}
*lineptr = tmp;
*n = new_n;
}
(*lineptr)[pos++] = (char)c;
if (c == '\n') {
break;
}
}
if (pos == 0 && c == EOF) {
return CBM_NOT_FOUND;
}
(*lineptr)[pos] = '\0';
return (ssize_t)pos;
}
#endif
+165
View File
@@ -0,0 +1,165 @@
/*
* compat.h — Cross-platform compatibility macros and shims.
*
* Provides portable TLS, sleep, strdup/strndup, and getline across
* POSIX (macOS/Linux) and Windows. Include this instead of using
* platform-specific macros directly.
*/
#ifndef CBM_COMPAT_H
#define CBM_COMPAT_H
#include <stddef.h>
#include <stdio.h>
/* stdlib.h declares getenv (cbm_tmpdir) and, on Windows, _putenv_s (cbm_setenv/
* cbm_unsetenv). The x86-64 mingw toolchain pulled it in transitively, but the
* aarch64 (CLANGARM64) include chain does not, so include it directly — without
* it those calls become implicit declarations that conflict with the real
* stdlib.h types and fail to compile on native ARM64 Windows. */
#include <stdlib.h>
/* ── Thread-local storage ─────────────────────────────────────── */
/* _Thread_local is C11 standard — works on GCC, Clang, and MSVC (2019+).
* __declspec(thread) is MSVC-only and doesn't work on MinGW GCC. */
#define CBM_TLS _Thread_local
/* ── Sleep ────────────────────────────────────────────────────── */
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#define cbm_usleep(us) Sleep((DWORD)((us) / 1000))
#else
#include <unistd.h>
#define cbm_usleep(us) usleep((useconds_t)(us))
#endif
/* ── strdup / strndup ─────────────────────────────────────────── */
#ifdef _WIN32
#define cbm_strdup _strdup
/* Implemented in compat.c */
char *cbm_strndup(const char *s, size_t n);
#else
#define cbm_strdup strdup
#define cbm_strndup strndup
#endif
/* ── getline (Windows lacks it) ───────────────────────────────── */
#ifdef _WIN32
/* Implemented in compat.c */
ssize_t cbm_getline(char **lineptr, size_t *n, FILE *stream);
#else
#define cbm_getline getline
#endif
/* ── fileno ───────────────────────────────────────────────────── */
#ifdef _WIN32
#define cbm_fileno _fileno
#else
#define cbm_fileno fileno
#endif
/* ── strcasestr (Windows lacks it) ────────────────────────────── */
#ifdef _WIN32
/* Implemented in compat.c */
char *cbm_strcasestr(const char *haystack, const char *needle);
#else
#define cbm_strcasestr strcasestr
#endif
/* ── mkdir portability ───────────────────────────────────────── */
#ifdef _WIN32
#include <direct.h>
#define cbm_mkdir(path) _mkdir(path)
#else
#include <sys/stat.h>
#define cbm_mkdir(path) mkdir(path, 0755)
#endif
/* ── clock_gettime / nanosleep (Windows lacks them) ──────────── */
#include <time.h>
#ifdef _WIN32
#ifndef CLOCK_MONOTONIC
#define CLOCK_MONOTONIC 1
#endif
/* Implemented in compat.c */
int cbm_clock_gettime(int clk_id, struct timespec *tp);
static inline int cbm_nanosleep(const struct timespec *req, struct timespec *rem) {
(void)rem;
Sleep((DWORD)(req->tv_sec * 1000 + req->tv_nsec / 1000000));
return 0;
}
#else
#define cbm_clock_gettime clock_gettime
#define cbm_nanosleep nanosleep
#endif
/* ── gmtime_r (Windows lacks it) ─────────────────────────────── */
#ifdef _WIN32
static inline struct tm *cbm_gmtime_r(const time_t *timep, struct tm *result) {
return gmtime_s(result, timep) == 0 ? result : NULL;
}
#else
#define cbm_gmtime_r gmtime_r
#endif
/* ── mkdtemp (Windows lacks it) ──────────────────────────────── */
#ifdef _WIN32
/* Translates /tmp/ to %TEMP%\ and copies result back to tmpl.
* Callers MUST use char buf[CBM_SZ_256] or larger. */
char *cbm_mkdtemp(char *tmpl);
#else
#define cbm_mkdtemp mkdtemp
#endif
/* ── mkstemp (Windows lacks it) ──────────────────────────────── */
#ifdef _WIN32
int cbm_mkstemp(char *tmpl);
#else
#define cbm_mkstemp mkstemp
#endif
/* ── setenv / unsetenv (Windows lacks them) ──────────────────── */
#ifdef _WIN32
static inline int cbm_setenv(const char *name, const char *value, int overwrite) {
(void)overwrite;
return _putenv_s(name, value);
}
static inline int cbm_unsetenv(const char *name) {
return _putenv_s(name, "");
}
#else
#define cbm_setenv setenv
#define cbm_unsetenv unsetenv
#endif
/* ── pipe (Windows uses _pipe) ───────────────────────────────── */
#ifdef _WIN32
#include <io.h>
#include <fcntl.h>
#define cbm_pipe(fds) _pipe(fds, 4096, _O_BINARY)
#else
#define cbm_pipe(fds) pipe(fds)
#endif
/* ── Temp directory helper ───────────────────────────────────── */
static inline const char *cbm_tmpdir(void) {
#ifdef _WIN32
const char *t = getenv("TEMP");
if (!t)
t = getenv("TMP");
return t ? t : ".";
#else
return "/tmp";
#endif
}
/* ── Signal handling ──────────────────────────────────────────── */
/* Windows doesn't have sigaction; provide macro to select signal API. */
#ifdef _WIN32
#define CBM_HAS_SIGACTION 0
#else
#define CBM_HAS_SIGACTION 1
#endif
#endif /* CBM_COMPAT_H */
+804
View File
@@ -0,0 +1,804 @@
/*
* compat_fs.c — Portable file system operations.
*
* POSIX: direct wrappers around opendir/readdir/closedir, popen/pclose, mkdir, unlink.
* Windows: FindFirstFile/FindNextFile, _popen/_pclose, _mkdir, _unlink.
*/
#include "foundation/constants.h"
#include "foundation/compat_fs.h"
#include "foundation/compat_fs_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
/* ── Windows implementation ────────────────────────────────── */
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <direct.h> /* _wmkdir */
#include <errno.h> /* errno for spawn-failure logging */
#include <fcntl.h> /* _O_RDONLY */
#include <io.h> /* _wunlink, _open_osfhandle, _close */
#include <stdint.h> /* intptr_t */
#include "foundation/log.h"
#include "foundation/win_utf8.h"
struct cbm_dir {
HANDLE find_handle;
WIN32_FIND_DATAW find_data;
wchar_t wide_pattern[CBM_PATH_MAX];
cbm_dirent_t entry;
bool first;
bool done;
};
cbm_dir_t *cbm_opendir(const char *path) {
if (!path) {
return NULL;
}
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return NULL;
}
size_t wlen = wcslen(wpath);
if (wlen == 0 || wlen + 2 >= CBM_PATH_MAX) {
free(wpath);
return NULL;
}
cbm_dir_t *d = (cbm_dir_t *)calloc(CBM_ALLOC_ONE, sizeof(cbm_dir_t));
if (!d) {
free(wpath);
return NULL;
}
wmemcpy(d->wide_pattern, wpath, wlen + 1);
wchar_t *p = d->wide_pattern + wlen - SKIP_ONE;
if (*p != L'\\' && *p != L'/') {
++p;
*p++ = L'\\';
} else {
++p;
}
*p++ = L'*';
*p = L'\0';
free(wpath);
d->find_handle = FindFirstFileW(d->wide_pattern, &d->find_data);
if (d->find_handle == INVALID_HANDLE_VALUE) {
free(d);
return NULL;
}
d->first = true;
d->done = false;
return d;
}
cbm_dirent_t *cbm_readdir(cbm_dir_t *d) {
if (!d || d->done) {
return NULL;
}
if (!d->first) {
if (!FindNextFileW(d->find_handle, &d->find_data)) {
d->done = true;
return NULL;
}
}
d->first = false;
while (d->find_data.cFileName[0] == L'.' &&
(d->find_data.cFileName[1] == L'\0' ||
(d->find_data.cFileName[1] == L'.' && d->find_data.cFileName[2] == L'\0'))) {
if (!FindNextFileW(d->find_handle, &d->find_data)) {
d->done = true;
return NULL;
}
}
char *u8 = cbm_wide_to_utf8(d->find_data.cFileName);
if (!u8) {
d->done = true;
return NULL;
}
size_t nlen = strlen(u8);
if (nlen >= CBM_DIRENT_NAME_MAX) {
nlen = CBM_DIRENT_NAME_MAX - SKIP_ONE;
}
memcpy(d->entry.name, u8, nlen);
d->entry.name[nlen] = '\0';
free(u8);
d->entry.is_dir = (d->find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
d->entry.d_type = 0;
return &d->entry;
}
void cbm_closedir(cbm_dir_t *d) {
if (d) {
if (d->find_handle != INVALID_HANDLE_VALUE) {
FindClose(d->find_handle);
}
free(d);
}
}
/* Windows _popen replacement that inherits ONLY the child's stdout pipe.
*
* The CRT's _popen uses CreateProcess(bInheritHandles=TRUE), which leaks EVERY
* inheritable handle we hold into the child — listening/client sockets, the
* Winsock/AFD helper handles created by WSAStartup, the MCP stdio pipe, etc.
* When the child is git-for-Windows (MSYS2/Cygwin runtime), its startup walks
* every inherited handle and calls NtQueryObject on each to classify it; on an
* inherited socket/AFD handle NtQueryObject deadlocks. Since our UI server runs
* requests on a single thread, that wedges the whole server (list_projects,
* which shells out to git per project, never returns → the web UI hangs).
*
* The fix: spawn via CreateProcessW with STARTUPINFOEXW + an explicit
* PROC_THREAD_ATTRIBUTE_HANDLE_LIST containing only the stdout write-end and a
* NUL handle for stdin/stderr. Nothing else crosses into git, so there is no
* foreign handle to deadlock on. POSIX popen() already sets O_CLOEXEC on its
* pipe, so the POSIX path is unchanged.
*
* There is deliberately NO fallback to _popen when the isolated spawn fails:
* falling back would silently re-arm the deadlock. cbm_popen logs a structured
* warning and returns NULL instead (every call site handles NULL). */
enum { CBM_POPEN_MAX = 16 };
static struct {
FILE *fp;
HANDLE proc;
} g_popen_tab[CBM_POPEN_MAX];
static CRITICAL_SECTION g_popen_lock;
static INIT_ONCE g_popen_once = INIT_ONCE_STATIC_INIT;
/* Test hook (declared in compat_fs_internal.h): 1 when the most recent
* cbm_popen(..., "r") stream came from the isolated spawn. Test-only
* observable; not synchronized across threads. */
static volatile LONG g_popen_last_isolated = 0;
int cbm_popen_last_was_isolated(void) {
return (int)g_popen_last_isolated;
}
static BOOL CALLBACK cbm_popen_init(PINIT_ONCE once, PVOID param, PVOID *ctx) {
(void)once;
(void)param;
(void)ctx;
InitializeCriticalSection(&g_popen_lock);
return TRUE;
}
/* Resolve the shell explicitly — %COMSPEC%, else <system dir>\cmd.exe — so it
* can be passed as lpApplicationName and CreateProcess never walks the search
* path (no cmd.exe planting from a hostile CWD). Heap string; caller frees. */
static wchar_t *cbm_resolve_comspec(void) {
wchar_t buf[MAX_PATH];
const wchar_t suffix[] = L"\\cmd.exe";
DWORD n = GetEnvironmentVariableW(L"COMSPEC", buf, MAX_PATH);
if (n == 0 || n >= MAX_PATH) {
UINT sn = GetSystemDirectoryW(buf, MAX_PATH);
if (sn == 0 || (size_t)sn + wcslen(suffix) >= MAX_PATH) {
return NULL;
}
wmemcpy(buf + sn, suffix, wcslen(suffix) + 1);
}
return _wcsdup(buf);
}
/* On failure returns NULL with *stage naming the failing step and *gle the
* GetLastError value captured at that step (0 when errno is the signal). */
static FILE *cbm_popen_isolated(const char *cmd, const char **stage, DWORD *gle) {
*stage = "";
*gle = 0;
InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL);
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = NULL;
sa.bInheritHandle = TRUE;
HANDLE rd = NULL, wr = NULL;
if (!CreatePipe(&rd, &wr, &sa, 0)) {
*stage = "pipe";
*gle = GetLastError();
return NULL;
}
/* The parent read-end must never cross into the child. */
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
/* NUL for the child's stdin/stderr so it never touches our real stdin
* pipe. If NUL cannot be opened, fail: STARTF_USESTDHANDLES slots must
* never carry INVALID_HANDLE_VALUE. */
HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, NULL);
if (nul == INVALID_HANDLE_VALUE) {
*stage = "nul";
*gle = GetLastError();
CloseHandle(rd);
CloseHandle(wr);
return NULL;
}
HANDLE inherit[2];
inherit[0] = wr;
inherit[1] = nul;
SIZE_T attr_sz = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &attr_sz);
LPPROC_THREAD_ATTRIBUTE_LIST attr = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attr_sz);
BOOL attr_init = attr && InitializeProcThreadAttributeList(attr, 1, 0, &attr_sz);
BOOL prepared =
attr_init && UpdateProcThreadAttribute(attr, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit,
sizeof(inherit), NULL, NULL);
DWORD attr_gle = prepared ? 0 : GetLastError();
STARTUPINFOEXW si;
ZeroMemory(&si, sizeof(si));
si.StartupInfo.cb = sizeof(si);
si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
si.StartupInfo.hStdInput = nul;
si.StartupInfo.hStdOutput = wr;
si.StartupInfo.hStdError = nul;
si.lpAttributeList = attr;
/* Run through cmd.exe /c so command quoting and `2>NUL` behave as under
* _popen. The command line is heap-composed (no fixed-size truncation)
* and widened via UTF-8 so non-ASCII repo paths survive intact. */
wchar_t *app = cbm_resolve_comspec();
wchar_t *wcmdline = NULL;
if (app) {
size_t u8len = strlen(cmd) + sizeof("cmd.exe /c ");
char *u8 = (char *)malloc(u8len);
if (u8) {
snprintf(u8, u8len, "cmd.exe /c %s", cmd);
wcmdline = cbm_utf8_to_wide(u8);
free(u8);
}
}
PROCESS_INFORMATION pi;
ZeroMemory(&pi, sizeof(pi));
BOOL created = FALSE;
if (!prepared) {
*stage = "attr";
*gle = attr_gle;
} else if (!app || !wcmdline) {
*stage = "cmdline";
*gle = ERROR_NOT_ENOUGH_MEMORY;
} else {
created = CreateProcessW(app, wcmdline, NULL, NULL, TRUE, EXTENDED_STARTUPINFO_PRESENT,
NULL, NULL, &si.StartupInfo, &pi);
if (!created) {
*stage = "spawn";
*gle = GetLastError();
}
}
free(app);
free(wcmdline);
if (attr) {
if (attr_init) {
DeleteProcThreadAttributeList(attr);
}
free(attr);
}
CloseHandle(wr); /* the child owns the write-end now */
CloseHandle(nul);
if (!created) {
CloseHandle(rd);
return NULL;
}
CloseHandle(pi.hThread);
int fd = _open_osfhandle((intptr_t)rd, _O_RDONLY);
if (fd == -1) {
*stage = "osfhandle";
CloseHandle(rd);
CloseHandle(pi.hProcess);
return NULL;
}
FILE *fp = _fdopen(fd, "r"); /* takes ownership of fd/rd */
if (!fp) {
*stage = "fdopen";
_close(fd);
CloseHandle(pi.hProcess);
return NULL;
}
EnterCriticalSection(&g_popen_lock);
for (int i = 0; i < CBM_POPEN_MAX; i++) {
if (!g_popen_tab[i].fp) {
g_popen_tab[i].fp = fp;
g_popen_tab[i].proc = pi.hProcess;
LeaveCriticalSection(&g_popen_lock);
return fp;
}
}
LeaveCriticalSection(&g_popen_lock);
/* Table full (shouldn't happen): don't leak the process handle. */
*stage = "table";
CloseHandle(pi.hProcess);
fclose(fp);
return NULL;
}
FILE *cbm_popen(const char *cmd, const char *mode) {
/* Our git shell-outs are all read-mode; they MUST use the isolated
* spawn. On failure, log and fail the call — never fall back to
* _popen, whose full handle inheritance re-arms the UI hang (#798). */
if (mode && mode[0] == 'r' && mode[1] == '\0') {
const char *stage = "";
DWORD gle = 0;
FILE *fp = cbm_popen_isolated(cmd, &stage, &gle);
g_popen_last_isolated = (fp != NULL);
if (!fp) {
char glebuf[CBM_SZ_16];
char errnobuf[CBM_SZ_16];
snprintf(glebuf, sizeof(glebuf), "%lu", (unsigned long)gle);
snprintf(errnobuf, sizeof(errnobuf), "%d", errno);
cbm_log_warn("compat.popen_isolated_failed", "stage", stage, "gle", glebuf, "errno",
errnobuf);
}
return fp;
}
g_popen_last_isolated = 0;
return _popen(cmd, mode);
}
int cbm_pclose(FILE *f) {
InitOnceExecuteOnce(&g_popen_once, cbm_popen_init, NULL, NULL);
HANDLE proc = NULL;
EnterCriticalSection(&g_popen_lock);
for (int i = 0; i < CBM_POPEN_MAX; i++) {
if (g_popen_tab[i].fp == f) {
proc = g_popen_tab[i].proc;
g_popen_tab[i].fp = NULL;
g_popen_tab[i].proc = NULL;
break;
}
}
LeaveCriticalSection(&g_popen_lock);
if (!proc) {
return _pclose(f); /* opened via _popen (non-read mode) */
}
fclose(f);
WaitForSingleObject(proc, INFINITE);
DWORD code = 0;
BOOL got = GetExitCodeProcess(proc, &code);
CloseHandle(proc);
return got ? (int)code : -1;
}
FILE *cbm_fopen(const char *path, const char *mode) {
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return NULL;
}
wchar_t *wmode = cbm_utf8_to_wide(mode);
if (!wmode) {
free(wpath);
return NULL;
}
FILE *f = _wfopen(wpath, wmode);
free(wpath);
free(wmode);
return f;
}
bool cbm_mkdir_p(const char *path, int mode) {
(void)mode;
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return false;
}
if (_wmkdir(wpath) == 0) {
free(wpath);
return true;
}
size_t wlen = wcslen(wpath);
wchar_t *tmp = (wchar_t *)malloc((wlen + 1) * sizeof(wchar_t));
if (!tmp) {
free(wpath);
return false;
}
wmemcpy(tmp, wpath, wlen + 1);
for (wchar_t *p = tmp + SKIP_ONE; *p; p++) {
if (*p == L'/' || *p == L'\\') {
*p = L'\0';
_wmkdir(tmp);
*p = L'\\';
}
}
bool ok = _wmkdir(tmp) == 0 || GetLastError() == ERROR_ALREADY_EXISTS;
free(tmp);
free(wpath);
return ok;
}
int cbm_unlink(const char *path) {
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return CBM_NOT_FOUND;
}
int ret = _wunlink(wpath);
free(wpath);
return ret;
}
int cbm_rmdir(const char *path) {
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return CBM_NOT_FOUND;
}
int ret = _wrmdir(wpath);
free(wpath);
return ret;
}
/* Build a properly-quoted Windows command line from an argv array.
* Returns a heap-allocated wide string, or NULL on allocation failure.
* Quoting follows the MSVC CRT convention: arguments containing spaces,
* tabs, or double-quotes are wrapped in double-quotes, with backslashes
* before a closing quote doubled and the quote itself escaped. Argument
* bytes are treated as UTF-8 and converted to wide via cbm_utf8_to_wide,
* so non-ASCII arguments (e.g. a non-ASCII %USERPROFILE%) survive intact.
* Declared in compat_fs_internal.h so the test suite can drive it. */
wchar_t *cbm_build_cmdline(const char *const *argv) {
/* First pass: compute required buffer size. */
size_t total = 1; /* NUL terminator */
for (int i = 0; argv[i]; i++) {
const char *arg = argv[i];
bool needs_quote = (arg[0] == '\0');
for (const char *p = arg; *p; p++) {
if (*p == ' ' || *p == '\t' || *p == '"') {
needs_quote = true;
}
}
if (i > 0) {
total++; /* space separator */
}
if (needs_quote) {
total += 2; /* opening and closing quote */
size_t backslashes = 0;
for (const char *p = arg; *p; p++) {
if (*p == '\\') {
backslashes++;
} else if (*p == '"') {
total += backslashes + 1; /* double backslashes + escape backslash */
backslashes = 0;
} else {
backslashes = 0;
}
total++;
}
/* Trailing backslashes before closing quote must be doubled. */
total += backslashes;
} else {
total += strlen(arg);
}
}
/* Build the quoted command line in UTF-8 first, then widen it as a
* whole via cbm_utf8_to_wide. Every character the quoting logic acts
* on (space, tab, '"', '\\') is ASCII and, by UTF-8's design, never
* appears inside a multibyte sequence, so operating on raw bytes here
* is safe and keeps multibyte argument bytes intact for conversion. */
char *buf = (char *)malloc(total);
if (!buf) {
return NULL;
}
/* Second pass: write the command line bytes. */
char *w = buf;
for (int i = 0; argv[i]; i++) {
const char *arg = argv[i];
bool needs_quote = (arg[0] == '\0');
for (const char *p = arg; *p; p++) {
if (*p == ' ' || *p == '\t' || *p == '"') {
needs_quote = true;
break;
}
}
if (i > 0) {
*w++ = ' ';
}
if (needs_quote) {
*w++ = '"';
size_t backslashes = 0;
for (const char *p = arg; *p; p++) {
if (*p == '\\') {
backslashes++;
*w++ = '\\';
} else if (*p == '"') {
/* Double the preceding backslashes, then escape the quote. */
for (size_t b = 0; b < backslashes; b++) {
*w++ = '\\';
}
*w++ = '\\';
*w++ = '"';
backslashes = 0;
} else {
backslashes = 0;
*w++ = *p;
}
}
/* Double trailing backslashes before the closing quote. */
for (size_t b = 0; b < backslashes; b++) {
*w++ = '\\';
}
*w++ = '"';
} else {
for (const char *p = arg; *p; p++) {
*w++ = *p;
}
}
}
*w = '\0';
wchar_t *out = cbm_utf8_to_wide(buf);
free(buf);
return out;
}
int cbm_exec_no_shell(const char *const *argv) {
if (!argv || !argv[0]) {
return CBM_NOT_FOUND;
}
wchar_t *cmdline = cbm_build_cmdline(argv);
if (!cmdline) {
return CBM_NOT_FOUND;
}
STARTUPINFOW si;
PROCESS_INFORMATION pi;
memset(&si, 0, sizeof(si));
memset(&pi, 0, sizeof(pi));
si.cb = sizeof(si);
if (!CreateProcessW(NULL, cmdline, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) {
free(cmdline);
return CBM_NOT_FOUND;
}
free(cmdline);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD exit_code = (DWORD)CBM_NOT_FOUND;
GetExitCodeProcess(pi.hProcess, &exit_code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return (int)exit_code;
}
#else /* POSIX */
/* ── POSIX implementation ────────────────────────────────── */
#include <dirent.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
struct cbm_dir {
DIR *dir;
cbm_dirent_t entry;
};
cbm_dir_t *cbm_opendir(const char *path) {
if (!path) {
return NULL;
}
DIR *dir = opendir(path);
if (!dir) {
return NULL;
}
cbm_dir_t *d = (cbm_dir_t *)calloc(CBM_ALLOC_ONE, sizeof(cbm_dir_t));
if (!d) {
closedir(dir);
return NULL;
}
d->dir = dir;
return d;
}
cbm_dirent_t *cbm_readdir(cbm_dir_t *d) {
if (!d || !d->dir) {
return NULL;
}
struct dirent *de;
while ((de = readdir(d->dir)) != NULL) {
/* Skip "." and ".." */
if (de->d_name[0] == '.' &&
(de->d_name[SKIP_ONE] == '\0' ||
(de->d_name[SKIP_ONE] == '.' && de->d_name[PAIR_LEN] == '\0'))) {
continue;
}
size_t nlen = strlen(de->d_name);
if (nlen >= CBM_DIRENT_NAME_MAX) {
nlen = CBM_DIRENT_NAME_MAX - SKIP_ONE;
}
memcpy(d->entry.name, de->d_name, nlen);
d->entry.name[nlen] = '\0';
d->entry.is_dir = (de->d_type == DT_DIR);
d->entry.d_type = de->d_type;
return &d->entry;
}
return NULL;
}
void cbm_closedir(cbm_dir_t *d) {
if (d) {
if (d->dir) {
closedir(d->dir);
}
free(d);
}
}
FILE *cbm_popen(const char *cmd, const char *mode) {
return popen(cmd, mode);
}
int cbm_pclose(FILE *f) {
return pclose(f);
}
FILE *cbm_fopen(const char *path, const char *mode) {
return fopen(path, mode);
}
bool cbm_mkdir_p(const char *path, int mode) {
/* Try direct mkdir first */
if (mkdir(path, (mode_t)mode) == 0) {
return true;
}
/* Walk path and create each component */
char *tmp = strdup(path);
if (!tmp) {
return false;
}
for (char *p = tmp + SKIP_ONE; *p; p++) {
if (*p == '/') {
*p = '\0';
mkdir(tmp, (mode_t)mode); /* ignore intermediate errors */
*p = '/';
}
}
bool ok = (mkdir(tmp, (mode_t)mode) == 0 || errno == EEXIST) != 0;
free(tmp);
return ok;
}
int cbm_unlink(const char *path) {
return unlink(path);
}
int cbm_rmdir(const char *path) {
return rmdir(path);
}
int cbm_exec_no_shell(const char *const *argv) {
if (!argv || !argv[0]) {
return CBM_NOT_FOUND;
}
pid_t pid = fork();
if (pid < 0) {
return CBM_NOT_FOUND;
}
if (pid == 0) {
/* Child: exec directly — no shell interpretation */
/* 127 = standard "command not found" exit code (POSIX convention) */
enum { EXEC_NOT_FOUND = 127 };
execvp(argv[0], (char *const *)argv);
_exit(EXEC_NOT_FOUND);
}
/* Parent: wait for child */
int status = 0;
if (waitpid(pid, &status, 0) < 0) {
return CBM_NOT_FOUND;
}
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
return CBM_NOT_FOUND; /* killed by signal */
}
#endif /* _WIN32 */
/* Canonicalize an EXISTING path (collapse `..`, resolve per-OS): realpath on
* POSIX; on Windows a wide-path GetFileAttributesW existence check +
* GetFullPathNameW. The previous callers used the ANSI CRT (_access/
* _fullpath) on UTF-8 input — locale-dependent by construction: on a CJK
* system codepage (e.g. Big5) the UTF-8 bytes of a CJK path re-decode into
* different characters and canonicalization corrupts the path (#973).
* Returns 0 when the path does not exist or cannot be resolved. */
int cbm_canonical_path(const char *path, char *out, size_t out_sz) {
if (!path || !out || out_sz == 0) {
return 0;
}
#ifdef _WIN32
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return 0;
}
if (GetFileAttributesW(wpath) == INVALID_FILE_ATTRIBUTES) {
free(wpath);
return 0;
}
enum { CANON_WIDE_MAX = 4096 };
wchar_t wfull[CANON_WIDE_MAX];
DWORD n = GetFullPathNameW(wpath, CANON_WIDE_MAX, wfull, NULL);
free(wpath);
if (n == 0 || n >= CANON_WIDE_MAX) {
return 0;
}
char *utf8 = cbm_wide_to_utf8(wfull);
if (!utf8) {
return 0;
}
size_t len = strlen(utf8);
if (len >= out_sz) {
free(utf8);
return 0;
}
memcpy(out, utf8, len + 1);
free(utf8);
return 1;
#else
/* Callers pass >= 4K buffers (>= PATH_MAX on our platforms). */
return realpath(path, out) != NULL;
#endif
}
/* rename() with overwrite semantics on every platform: POSIX rename already
* replaces atomically; Windows rename fails with EEXIST when the target
* exists, so use MoveFileExW(MOVEFILE_REPLACE_EXISTING) there (wide paths —
* raw MoveFileExA would re-mangle non-ASCII cache paths). */
int cbm_rename_replace(const char *src, const char *dst) {
#ifdef _WIN32
wchar_t *wsrc = cbm_utf8_to_wide(src);
wchar_t *wdst = cbm_utf8_to_wide(dst);
int ret = CBM_NOT_FOUND;
if (wsrc && wdst) {
ret = MoveFileExW(wsrc, wdst, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED)
? 0
: CBM_NOT_FOUND;
}
free(wsrc);
free(wdst);
return ret;
#else
return rename(src, dst);
#endif
}
/* Remove a SQLite database's -wal/-shm sidecars (both platforms). Any code
* path that installs a FRESH database file at a path where a previous
* generation lived must call this first: SQLite decides whether to replay a
* WAL purely from the sidecar's own header/checksums, so a leftover WAL
* from a crashed session is recovered ON TOP of the freshly installed file
* at the next open, splicing old-generation pages into it (#897). */
void cbm_remove_db_sidecars(const char *db_path) {
if (!db_path || !db_path[0]) {
return;
}
enum { SIDECAR_PATH_MAX = 4096 };
char side[SIDECAR_PATH_MAX];
int n = snprintf(side, sizeof(side), "%s-wal", db_path);
if (n > 0 && (size_t)n < sizeof(side)) {
(void)cbm_unlink(side);
}
n = snprintf(side, sizeof(side), "%s-shm", db_path);
if (n > 0 && (size_t)n < sizeof(side)) {
(void)cbm_unlink(side);
}
}
+76
View File
@@ -0,0 +1,76 @@
/*
* compat_fs.h — Portable directory iteration, popen, and file operations.
*
* POSIX: thin wrappers around opendir/readdir, popen/pclose, mkdir, unlink.
* Windows: FindFirstFile/FindNextFile, _popen/_pclose, _mkdir, _unlink.
*/
#ifndef CBM_COMPAT_FS_H
#define CBM_COMPAT_FS_H
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
/* ── Directory iteration ──────────────────────────────────────── */
/* Max filename length (MAX_PATH on Windows, NAME_MAX on POSIX). */
#define CBM_DIRENT_NAME_MAX 260
typedef struct cbm_dir cbm_dir_t;
typedef struct {
char name[CBM_DIRENT_NAME_MAX];
bool is_dir;
unsigned char d_type; /* DT_REG, DT_DIR, DT_LNK, etc. (POSIX only, 0 on Windows) */
} cbm_dirent_t;
/* Open a directory for iteration. Returns NULL on error. */
cbm_dir_t *cbm_opendir(const char *path);
/* Read next entry. Returns NULL when done. The returned pointer is
* valid until the next cbm_readdir call on the same handle. */
cbm_dirent_t *cbm_readdir(cbm_dir_t *d);
/* Close directory handle. */
void cbm_closedir(cbm_dir_t *d);
/* ── Portable popen/pclose ────────────────────────────────────── */
FILE *cbm_popen(const char *cmd, const char *mode);
int cbm_pclose(FILE *f);
/* ── File operations ──────────────────────────────────────────── */
/* Create directory (and parents). mode is ignored on Windows. Returns true on success. */
bool cbm_mkdir_p(const char *path, int mode);
/* Delete a file. Returns 0 on success. */
int cbm_unlink(const char *path);
/* Remove <db_path>-wal/-shm. MUST be called by any path installing a fresh
* DB file where a previous generation lived — a leftover WAL is otherwise
* replayed on top of the new file at the next open (#897). */
void cbm_remove_db_sidecars(const char *db_path);
/* rename() that replaces an existing destination on every platform
* (Windows rename fails with EEXIST; this uses MoveFileExW there). */
int cbm_rename_replace(const char *src, const char *dst);
/* Canonicalize an EXISTING path (realpath / wide GetFullPathNameW). Locale-
* independent on Windows — never routes UTF-8 through the ANSI CRT (#973).
* out must be >= 4096 bytes. Returns 1 on success, 0 otherwise. */
int cbm_canonical_path(const char *path, char *out, size_t out_sz);
/* Delete an empty directory. Returns 0 on success. */
int cbm_rmdir(const char *path);
/* Open a file by UTF-8 path.
* On Windows, converts to wide-char and calls _wfopen so paths with
* non-ASCII characters (accents, CJK, etc.) are handled correctly.
* On POSIX, delegates to fopen. mode must be an ASCII string. */
FILE *cbm_fopen(const char *path, const char *mode);
/* Execute a command without shell interpretation.
* argv is a NULL-terminated array: {"cmd", "arg1", "arg2", NULL}.
* Returns the process exit code, or -1 on fork/exec failure.
* POSIX: fork() + execvp(). Windows: CreateProcess with proper quoting. */
int cbm_exec_no_shell(const char *const *argv);
#endif /* CBM_COMPAT_FS_H */
+46
View File
@@ -0,0 +1,46 @@
/*
* compat_fs_internal.h — Internal helpers exposed for testing.
*
* These functions are implementation details of compat_fs.c; they are
* declared here only so that the test suite can drive them directly.
* Production code outside compat_fs.c should use the public APIs in
* compat_fs.h instead.
*/
#ifndef CBM_FOUNDATION_COMPAT_FS_INTERNAL_H
#define CBM_FOUNDATION_COMPAT_FS_INTERNAL_H
#ifdef _WIN32
#include <wchar.h>
/*
* Build a properly-quoted Windows command line from a NULL-terminated
* argv array. This is the quoting step underlying cbm_exec_no_shell on
* Windows: it is what turns {"taskkill", "/FI", "IMAGENAME eq foo.exe"}
* into `taskkill /FI "IMAGENAME eq foo.exe"` rather than three bare
* tokens (the #697 regression).
*
* Quoting follows the MSVC/CommandLineToArgvW convention: an argument is
* wrapped in double-quotes when it is empty or contains a space, tab, or
* double-quote; backslashes immediately before a quote (literal or the
* closing one) are doubled, and embedded double-quotes are escaped with a
* backslash.
*
* Returns a heap-allocated wide string the caller must free(), or NULL on
* allocation failure.
*/
wchar_t *cbm_build_cmdline(const char *const *argv);
/*
* Test hook for the isolated popen path (#798): returns 1 when the most
* recent cbm_popen(..., "r") stream was produced by the isolated
* CreateProcessW + PROC_THREAD_ATTRIBUTE_HANDLE_LIST spawn, 0 otherwise
* (e.g. a non-read mode routed to _popen, or a failed isolated spawn).
* Not synchronized across threads; intended for single-threaded test
* assertions only.
*/
int cbm_popen_last_was_isolated(void);
#endif /* _WIN32 */
#endif /* CBM_FOUNDATION_COMPAT_FS_INTERNAL_H */
+125
View File
@@ -0,0 +1,125 @@
/*
* compat_regex.c — Portable regular expression implementation.
*
* POSIX: direct wrappers around <regex.h>.
* Windows: vendored TRE regex library (BSD-licensed).
*/
#include "foundation/constants.h"
#include "foundation/compat_regex.h"
#include <string.h>
#ifdef _WIN32
/* ── Windows: use vendored TRE regex ─────────────────────────── */
#include "../../vendored/tre/regex.h"
_Static_assert(sizeof(regex_t) <= CBM_SZ_256,
"cbm_regex_t opaque buffer too small for TRE regex_t");
static int translate_flags_tre(int flags) {
int tre_flags = 0;
if (flags & CBM_REG_EXTENDED)
tre_flags |= REG_EXTENDED;
if (flags & CBM_REG_ICASE)
tre_flags |= REG_ICASE;
if (flags & CBM_REG_NOSUB)
tre_flags |= REG_NOSUB;
if (flags & CBM_REG_NEWLINE)
tre_flags |= REG_NEWLINE;
return tre_flags;
}
int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags) {
regex_t *re = (regex_t *)r->opaque;
int rc = tre_regcomp(re, pattern, translate_flags_tre(flags));
return rc == 0 ? CBM_REG_OK : rc;
}
int cbm_regexec(const cbm_regex_t *r, const char *str, int nmatch, cbm_regmatch_t *matches,
int eflags) {
const regex_t *re = (const regex_t *)r->opaque;
if (nmatch <= 0 || !matches) {
int rc = tre_regexec(re, str, 0, NULL, eflags);
return rc == 0 ? CBM_REG_OK : CBM_REG_NOMATCH;
}
regmatch_t pmatch[CBM_SZ_32];
int n = nmatch > CBM_SZ_32 ? CBM_SZ_32 : nmatch;
int rc = tre_regexec(re, str, (size_t)n, pmatch, eflags);
if (rc != 0)
return CBM_REG_NOMATCH;
for (int i = 0; i < n; i++) {
matches[i].rm_so = (int)pmatch[i].rm_so;
matches[i].rm_eo = (int)pmatch[i].rm_eo;
}
return CBM_REG_OK;
}
void cbm_regfree(cbm_regex_t *r) {
regex_t *re = (regex_t *)r->opaque;
tre_regfree(re);
}
#else /* POSIX */
/* ── POSIX implementation ─────────────────────────────────────── */
#include <regex.h>
/* Static assert: our opaque buffer is large enough for regex_t.
* If this fires, increase cbm_regex_t.opaque size. */
_Static_assert(sizeof(regex_t) <= CBM_SZ_256, "cbm_regex_t opaque buffer too small for regex_t");
static int translate_flags(int flags) {
int posix_flags = 0;
if (flags & CBM_REG_EXTENDED) {
posix_flags |= REG_EXTENDED;
}
if (flags & CBM_REG_ICASE) {
posix_flags |= REG_ICASE;
}
if (flags & CBM_REG_NOSUB) {
posix_flags |= REG_NOSUB;
}
if (flags & CBM_REG_NEWLINE) {
posix_flags |= REG_NEWLINE;
}
return posix_flags;
}
int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags) {
regex_t *re = (regex_t *)r->opaque;
int rc = regcomp(re, pattern, translate_flags(flags));
return rc == 0 ? CBM_REG_OK : rc;
}
int cbm_regexec(const cbm_regex_t *r, const char *str, int nmatch, cbm_regmatch_t *matches,
int eflags) {
const regex_t *re = (const regex_t *)r->opaque;
if (nmatch <= 0 || !matches) {
int rc = regexec(re, str, 0, NULL, eflags);
return rc == 0 ? CBM_REG_OK : CBM_REG_NOMATCH;
}
/* Map through POSIX regmatch_t */
regmatch_t pmatch[CBM_SZ_32];
int n = nmatch > CBM_SZ_32 ? CBM_SZ_32 : nmatch;
int rc = regexec(re, str, (size_t)n, pmatch, eflags);
if (rc != 0) {
return CBM_REG_NOMATCH;
}
for (int i = 0; i < n; i++) {
matches[i].rm_so = (int)pmatch[i].rm_so;
matches[i].rm_eo = (int)pmatch[i].rm_eo;
}
return CBM_REG_OK;
}
void cbm_regfree(cbm_regex_t *r) {
regex_t *re = (regex_t *)r->opaque;
regfree(re);
}
#endif /* _WIN32 */
+55
View File
@@ -0,0 +1,55 @@
/*
* compat_regex.h — Portable regular expression API.
*
* POSIX: direct wrappers around <regex.h> (regcomp, regexec, regfree).
* Windows: TODO — vendor TRE regex or use a C++ wrapper around <regex>.
*
* Uses our own types so callers never include <regex.h> directly.
*/
#ifndef CBM_COMPAT_REGEX_H
#define CBM_COMPAT_REGEX_H
#include "foundation/constants.h"
#include <stddef.h>
/* ── Flags ────────────────────────────────────────────────────── */
#define CBM_REG_EXTENDED 1
#define CBM_REG_ICASE 2
#define CBM_REG_NOSUB 4
#define CBM_REG_NEWLINE 8
/* ── Error codes ──────────────────────────────────────────────── */
#define CBM_REG_OK 0
#define CBM_REG_NOMATCH (-1)
/* ── Types ────────────────────────────────────────────────────── */
/* Opaque regex handle — sized to hold the platform's regex_t. */
typedef struct {
/* CBM_SZ_256 bytes should be large enough for any platform's regex_t.
* POSIX regex_t is typically 48-CBM_SZ_64 bytes; TRE is ~80 bytes. */
char opaque[CBM_SZ_256];
} cbm_regex_t;
typedef struct {
int rm_so; /* byte offset of match start, -1 if no match */
int rm_eo; /* byte offset past match end */
} cbm_regmatch_t;
/* ── Functions ────────────────────────────────────────────────── */
/* Compile a regular expression. Returns CBM_REG_OK on success, non-zero on error. */
int cbm_regcomp(cbm_regex_t *r, const char *pattern, int flags);
/* Execute compiled regex against str. nmatch/matches may be 0/NULL.
* eflags: 0 or combination of platform-specific exec flags.
* Returns CBM_REG_OK on match, CBM_REG_NOMATCH on no match. */
int cbm_regexec(const cbm_regex_t *r, const char *str, int nmatch, cbm_regmatch_t *matches,
int eflags);
/* Free compiled regex. */
void cbm_regfree(cbm_regex_t *r);
#endif /* CBM_COMPAT_REGEX_H */
+166
View File
@@ -0,0 +1,166 @@
/*
* compat_thread.c — Portable thread, mutex, and aligned allocation.
*
* POSIX: thin wrappers around pthreads and posix_memalign.
* Windows: CreateThread, CRITICAL_SECTION, _aligned_malloc.
*/
#include "foundation/constants.h"
#include "foundation/compat_thread.h"
#include <pthread.h>
#include <stdlib.h>
/* Default 8MB stack for all threads. macOS ARM64 default is only 512KB,
* which is too small for deep pipeline passes (configlink, etc.). */
#define CBM_DEFAULT_STACK_SIZE ((size_t)8 * CBM_SZ_1K * CBM_SZ_1K)
#include <string.h>
/* ── Thread ───────────────────────────────────────────────────── */
#ifdef _WIN32
typedef struct {
void *(*fn)(void *);
void *arg;
} win_thread_arg_t;
static DWORD WINAPI win_thread_wrapper(LPVOID lpParam) {
win_thread_arg_t *a = (win_thread_arg_t *)lpParam;
void *(*fn)(void *) = a->fn;
void *arg = a->arg;
free(a);
fn(arg);
return 0;
}
int cbm_thread_create(cbm_thread_t *t, size_t stack_size, void *(*fn)(void *), void *arg) {
if (stack_size == 0) {
stack_size = CBM_DEFAULT_STACK_SIZE;
}
win_thread_arg_t *a = (win_thread_arg_t *)malloc(sizeof(win_thread_arg_t));
if (!a) {
return CBM_NOT_FOUND;
}
a->fn = fn;
a->arg = arg;
t->handle = CreateThread(NULL, stack_size, win_thread_wrapper, a, 0, NULL);
if (!t->handle) {
free(a);
return CBM_NOT_FOUND;
}
return 0;
}
int cbm_thread_join(cbm_thread_t *t) {
if (WaitForSingleObject(t->handle, INFINITE) != WAIT_OBJECT_0) {
return CBM_NOT_FOUND;
}
CloseHandle(t->handle);
t->handle = NULL;
return 0;
}
int cbm_thread_detach(cbm_thread_t *t) {
if (t->handle) {
CloseHandle(t->handle);
t->handle = NULL;
}
return 0;
}
#else /* POSIX */
int cbm_thread_create(cbm_thread_t *t, size_t stack_size, void *(*fn)(void *), void *arg) {
if (stack_size == 0) {
stack_size = CBM_DEFAULT_STACK_SIZE;
}
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, stack_size);
int rc = pthread_create(&t->handle, &attr, fn, arg);
pthread_attr_destroy(&attr);
return rc;
}
int cbm_thread_join(cbm_thread_t *t) {
int rc = pthread_join(t->handle, NULL);
if (rc == 0) {
memset(&t->handle, 0, sizeof(t->handle));
}
return rc;
}
int cbm_thread_detach(cbm_thread_t *t) {
int rc = pthread_detach(t->handle);
if (rc == 0) {
memset(&t->handle, 0, sizeof(t->handle));
}
return rc;
}
#endif
/* ── Mutex ────────────────────────────────────────────────────── */
#ifdef _WIN32
void cbm_mutex_init(cbm_mutex_t *m) {
InitializeCriticalSection(&m->cs);
}
void cbm_mutex_lock(cbm_mutex_t *m) {
EnterCriticalSection(&m->cs);
}
void cbm_mutex_unlock(cbm_mutex_t *m) {
LeaveCriticalSection(&m->cs);
}
void cbm_mutex_destroy(cbm_mutex_t *m) {
DeleteCriticalSection(&m->cs);
}
#else /* POSIX */
void cbm_mutex_init(cbm_mutex_t *m) {
pthread_mutex_init(&m->mtx, NULL);
}
void cbm_mutex_lock(cbm_mutex_t *m) {
pthread_mutex_lock(&m->mtx);
}
void cbm_mutex_unlock(cbm_mutex_t *m) {
pthread_mutex_unlock(&m->mtx);
}
void cbm_mutex_destroy(cbm_mutex_t *m) {
pthread_mutex_destroy(&m->mtx);
}
#endif
/* ── Aligned allocation ───────────────────────────────────────── */
#ifdef _WIN32
int cbm_aligned_alloc(void **ptr, size_t alignment, size_t size) {
*ptr = _aligned_malloc(size, alignment);
return *ptr ? 0 : -1;
}
void cbm_aligned_free(void *ptr) {
_aligned_free(ptr);
}
#else /* POSIX */
int cbm_aligned_alloc(void **ptr, size_t alignment, size_t size) {
return posix_memalign(ptr, alignment, size);
}
void cbm_aligned_free(void *ptr) {
free(ptr);
}
#endif
+75
View File
@@ -0,0 +1,75 @@
/*
* compat_thread.h — Portable threading: pthreads on POSIX, Win32 threads on Windows.
*
* Provides: thread create/join, mutex, aligned allocation.
* All have zero overhead on POSIX (thin inlines or macros).
*/
#ifndef CBM_COMPAT_THREAD_H
#define CBM_COMPAT_THREAD_H
#include <stddef.h>
/* ── Thread ───────────────────────────────────────────────────── */
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
typedef struct {
HANDLE handle;
} cbm_thread_t;
#else /* POSIX */
#include <pthread.h>
typedef struct {
pthread_t handle;
} cbm_thread_t;
#endif
/* Create a thread with the given stack size (0 = OS default).
* fn receives arg. Returns 0 on success. */
int cbm_thread_create(cbm_thread_t *t, size_t stack_size, void *(*fn)(void *), void *arg);
/* Wait for thread to finish. Returns 0 on success. */
int cbm_thread_join(cbm_thread_t *t);
/* Detach thread so resources are freed on exit. Returns 0 on success. */
int cbm_thread_detach(cbm_thread_t *t);
/* ── Mutex ────────────────────────────────────────────────────── */
#ifdef _WIN32
typedef struct {
CRITICAL_SECTION cs;
} cbm_mutex_t;
#else
typedef struct {
pthread_mutex_t mtx;
} cbm_mutex_t;
#endif
void cbm_mutex_init(cbm_mutex_t *m);
void cbm_mutex_lock(cbm_mutex_t *m);
void cbm_mutex_unlock(cbm_mutex_t *m);
void cbm_mutex_destroy(cbm_mutex_t *m);
/* ── Aligned allocation ───────────────────────────────────────── */
/* Allocate size bytes aligned to alignment boundary.
* Returns 0 on success, non-zero on failure. *ptr receives the allocation. */
int cbm_aligned_alloc(void **ptr, size_t alignment, size_t size);
/* Free memory from cbm_aligned_alloc. */
void cbm_aligned_free(void *ptr);
#endif /* CBM_COMPAT_THREAD_H */
+97
View File
@@ -0,0 +1,97 @@
/*
* constants.h — Project-wide named constants.
*
* Eliminates magic numbers flagged by readability-magic-numbers.
* Every literal integer/float in source should reference a named constant.
*/
#ifndef CBM_CONSTANTS_H
#define CBM_CONSTANTS_H
/* ── Allocation counts ───────────────────────────────────────── */
enum { CBM_ALLOC_ONE = 1 }; /* calloc(CBM_ALLOC_ONE, sizeof(T)) */
/* ── Byte / character constants ──────────────────────────────── */
enum {
CBM_BYTE_RANGE = 256, /* full byte range 0x000xFF */
CBM_QUOTE_PAIR = 2, /* two quote characters (open + close) */
CBM_QUOTE_OFFSET = 1, /* skip opening quote */
};
/* ── Size units (powers of 2) ────────────────────────────────── */
enum {
CBM_SZ_2 = 2,
CBM_SZ_3 = 3,
CBM_SZ_4 = 4,
CBM_SZ_5 = 5,
CBM_SZ_6 = 6,
CBM_SZ_7 = 7,
CBM_SZ_8 = 8,
CBM_SZ_16 = 16,
CBM_SZ_32 = 32,
CBM_SZ_64 = 64,
CBM_SZ_128 = 128,
CBM_SZ_256 = 256,
CBM_SZ_512 = 512,
CBM_SZ_1K = 1024,
CBM_SZ_2K = 2048,
CBM_SZ_4K = 4096,
CBM_SZ_8K = 8192,
CBM_SZ_16K = 16384,
CBM_SZ_32K = 32768,
CBM_SZ_64K = 65536,
};
/* ── Numeric bases and common factors ────────────────────────── */
enum {
CBM_DECIMAL_BASE = 10,
CBM_HEX_BASE = 16,
CBM_PERCENT = 100,
};
/* ── Tree-sitter field name helper ───────────────────────────── */
/* Usage: ts_node_child_by_field_name(node, TS_FIELD("callee"))
* Expands to: ts_node_child_by_field_name(node, TS_FIELD("callee"))
* The sizeof includes the NUL terminator, so subtract 1. */
#define TS_FIELD(name) (name), (uint32_t)(sizeof(name) - SKIP_ONE)
/* ── Tree-sitter line offset ─────────────────────────────────── */
/* ts_node row is 0-based; source lines are 1-based. */
enum { TS_LINE_OFFSET = 1 };
/* Common offset constants. */
/* Common offset constants. */
/* ── Sentinel values ─────────────────────────────────────────── */
enum {
CBM_NOT_FOUND = -1, /* search miss, invalid index */
CBM_INIT_DONE = 1, /* initialization flag */
};
/* ── Default pagination limits ───────────────────────────────── */
/* Default page size for search_graph and the underlying store-layer search.
* Responses land in an LLM agent's context window, so the default favors a
* cheap first page (~50 TOON rows ≈ 1.5K tokens) over raw coverage; the
* response always carries 'total' and 'has_more', and agents page via
* offset+limit or narrow with label/file_pattern when has_more is true. */
enum { CBM_DEFAULT_SEARCH_LIMIT = 50 };
/* ── Time conversion factors ─────────────────────────────────── */
#define CBM_NSEC_PER_SEC 1000000000ULL
#define CBM_USEC_PER_SEC 1000000ULL
#define CBM_MSEC_PER_SEC 1000ULL
#define CBM_NSEC_PER_USEC 1000ULL
#define CBM_NSEC_PER_MSEC 1000000ULL
/* ── Common string/buffer sizes ──────────────────────────────── */
enum {
CBM_SMALL_BUF = 3, /* small scratch buffers */
CBM_NAME_BUF = 4, /* name buffer slots */
CBM_PATH_MAX = 1024, /* path buffer size */
CBM_LINE_BUF = 512, /* line read buffer */
};
/* Common offset constants (used across many files). */
enum { SKIP_ONE = 1, PAIR_LEN = 2 };
#endif /* CBM_CONSTANTS_H */
+254
View File
@@ -0,0 +1,254 @@
/*
* diagnostics.c — Periodic diagnostics file writer.
*
* Writes JSON to /tmp/cbm-diagnostics-<pid>.json every 5 seconds.
* Atomic: writes .tmp then renames to avoid partial reads.
*/
#include "foundation/constants.h"
#include "foundation/diagnostics.h"
#include <stdatomic.h>
#include "foundation/mem.h"
#include "foundation/compat.h"
#include "foundation/compat_thread.h"
#include "foundation/compat_fs.h"
#include "foundation/platform.h"
#include <mimalloc.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifdef _WIN32
#include <process.h>
#define getpid _getpid
#else
#include <dirent.h>
#include <unistd.h>
#endif
/* ── Globals ─────────────────────────────────────────────────────── */
cbm_query_stats_t g_query_stats = {0};
static atomic_int g_diag_stop = 0;
static cbm_thread_t g_diag_thread;
static bool g_diag_started = false;
static time_t g_start_time = 0;
static char g_diag_path[CBM_SZ_256] = "";
/* Persistent NDJSON time-series (the memory TRAJECTORY users send us to diagnose
* slow leaks like #581). Unlike g_diag_path (a latest-snapshot file overwritten
* every interval and deleted on stop), this is appended-to and KEPT on exit. */
static char g_diag_ndjson_path[CBM_SZ_256] = "";
static size_t g_diag_ndjson_size = 0;
#define DIAG_NDJSON_CAP_BYTES (8u * 1024u * 1024u) /* rotate to .1 past this */
/* ── Query stats ─────────────────────────────────────────────────── */
void cbm_diag_record_query(long long duration_us, bool is_error) {
atomic_fetch_add(&g_query_stats.count, 1);
atomic_fetch_add(&g_query_stats.time_us, duration_us);
if (is_error) {
atomic_fetch_add(&g_query_stats.errors, 1);
}
/* Update max (lock-free CAS loop) */
long long old_max = atomic_load(&g_query_stats.max_us);
while (duration_us > old_max) {
if (atomic_compare_exchange_weak(&g_query_stats.max_us, &old_max, duration_us)) {
break;
}
}
}
/* ── FD count (platform-specific) ────────────────────────────────── */
static int count_open_fds(void) {
#ifdef __linux__
struct dirent **entries = NULL;
int n = scandir("/proc/self/fd", &entries, NULL, NULL);
if (n < 0) {
return CBM_NOT_FOUND;
}
for (int i = 0; i < n; i++) {
free(entries[i]);
}
free(entries);
return n - PAIR_LEN; /* . and .. */
#elif defined(__APPLE__)
/* Count via /dev/fd using scandir (MT-safe) */
struct dirent **entries = NULL;
int n = scandir("/dev/fd", &entries, NULL, NULL);
if (n < 0) {
return CBM_NOT_FOUND;
}
for (int i = 0; i < n; i++) {
free(entries[i]);
}
free(entries);
return n - PAIR_LEN; /* . and .. */
#else
return CBM_NOT_FOUND; /* Not available on Windows */
#endif
}
/* ── Writer ──────────────────────────────────────────────────────── */
#define DIAG_INTERVAL_S 5
#define DIAG_PATH_EXTRA 24 /* ".tmp" + safety margin */
/* Append one compact JSON line to the persistent NDJSON trajectory, rotating to
* <path>.1 once it passes the cap. Best-effort: a failed append never disrupts
* the server. The trajectory (a monotonic rss/committed climb over hours) is
* what reveals a slow leak like #581 — the single latest-snapshot file cannot. */
static void append_trajectory(long uptime, size_t rss, size_t peak_rss, size_t commit,
size_t peak_commit, size_t page_faults, int fds, int qcount) {
if (g_diag_ndjson_path[0] == '\0') {
return;
}
if (g_diag_ndjson_size > DIAG_NDJSON_CAP_BYTES) {
char rot[sizeof(g_diag_ndjson_path) + DIAG_PATH_EXTRA];
snprintf(rot, sizeof(rot), "%s.1", g_diag_ndjson_path);
(void)rename(g_diag_ndjson_path, rot); /* keep one previous generation */
g_diag_ndjson_size = 0;
}
FILE *f = fopen(g_diag_ndjson_path, "a");
if (!f) {
return;
}
int n = fprintf(f,
"{\"uptime_s\":%ld,\"rss\":%zu,\"peak_rss\":%zu,\"committed\":%zu,"
"\"peak_committed\":%zu,\"page_faults\":%zu,\"fd\":%d,\"queries\":%d}\n",
uptime, rss, peak_rss, commit, peak_commit, page_faults, fds, qcount);
if (n > 0) {
g_diag_ndjson_size += (size_t)n;
}
(void)fclose(f);
}
static void write_diagnostics(void) {
/* Collect mimalloc stats */
size_t elapsed_ms = 0;
size_t user_ms = 0;
size_t sys_ms = 0;
size_t current_rss = 0;
size_t peak_rss = 0;
size_t current_commit = 0;
size_t peak_commit = 0;
size_t page_faults = 0;
mi_process_info(&elapsed_ms, &user_ms, &sys_ms, &current_rss, &peak_rss, &current_commit,
&peak_commit, &page_faults);
/* Fallback RSS for ASan builds */
if (current_rss == 0) {
current_rss = cbm_mem_rss();
}
int fds = count_open_fds();
time_t now = time(NULL);
long uptime = (long)(now - g_start_time);
int qcount = atomic_load(&g_query_stats.count);
int qerrors = atomic_load(&g_query_stats.errors);
long long qtime = atomic_load(&g_query_stats.time_us);
long long qmax = atomic_load(&g_query_stats.max_us);
long long qavg = qcount > 0 ? qtime / qcount : 0;
/* Write to .tmp then rename (atomic) */
char tmp_path[sizeof(g_diag_path) + DIAG_PATH_EXTRA];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path);
FILE *f = fopen(tmp_path, "w");
if (!f) {
return;
}
if (fprintf(f,
"{\n"
" \"uptime_s\": %ld,\n"
" \"rss_bytes\": %zu,\n"
" \"peak_rss_bytes\": %zu,\n"
" \"heap_committed_bytes\": %zu,\n"
" \"peak_committed_bytes\": %zu,\n"
" \"page_faults\": %zu,\n"
" \"fd_count\": %d,\n"
" \"query_count\": %d,\n"
" \"query_errors\": %d,\n"
" \"query_total_us\": %lld,\n"
" \"query_avg_us\": %lld,\n"
" \"query_max_us\": %lld,\n"
" \"pid\": %d\n"
"}\n",
uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds,
qcount, qerrors, qtime, qavg, qmax, (int)getpid()) < 0) {
(void)fclose(f);
return;
}
if (fclose(f) != 0) {
return;
}
(void)rename(tmp_path, g_diag_path);
/* Also append to the persistent trajectory (kept on exit for users to send). */
append_trajectory(uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds,
qcount);
}
static void *diag_thread_fn(void *arg) {
(void)arg;
while (!atomic_load(&g_diag_stop)) {
write_diagnostics();
struct timespec ts = {DIAG_INTERVAL_S, 0};
cbm_nanosleep(&ts, NULL);
}
/* Final write before exit */
write_diagnostics();
return NULL;
}
/* ── Public API ──────────────────────────────────────────────────── */
bool cbm_diag_start(void) {
char env_buf[CBM_SZ_32] = "";
cbm_safe_getenv("CBM_DIAGNOSTICS", env_buf, sizeof(env_buf), NULL);
if (env_buf[0] == '\0' || (strcmp(env_buf, "1") != 0 && strcmp(env_buf, "true") != 0)) {
return false;
}
g_start_time = time(NULL);
atomic_store(&g_diag_stop, 0);
snprintf(g_diag_path, sizeof(g_diag_path), "%s/cbm-diagnostics-%d.json", cbm_tmpdir(),
(int)getpid());
snprintf(g_diag_ndjson_path, sizeof(g_diag_ndjson_path), "%s/cbm-diagnostics-%d.ndjson",
cbm_tmpdir(), (int)getpid());
g_diag_ndjson_size = 0;
if (cbm_thread_create(&g_diag_thread, 0, diag_thread_fn, NULL) != 0) {
return false;
}
g_diag_started = true;
(void)fprintf(stderr,
"level=info msg=diagnostics.start snapshot=%s trajectory=%s interval=%ds\n",
g_diag_path, g_diag_ndjson_path, DIAG_INTERVAL_S);
return true;
}
void cbm_diag_stop(void) {
if (!g_diag_started) {
return;
}
atomic_store(&g_diag_stop, 1);
cbm_thread_join(&g_diag_thread);
g_diag_started = false;
/* Remove the live latest-snapshot file (and its .tmp), but KEEP the
* trajectory NDJSON so the user can send it after the server exits. */
cbm_unlink(g_diag_path);
char tmp_path[sizeof(g_diag_path) + DIAG_PATH_EXTRA];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", g_diag_path);
cbm_unlink(tmp_path);
if (g_diag_ndjson_path[0] != '\0') {
(void)fprintf(stderr, "level=info msg=diagnostics.trajectory_kept path=%s\n",
g_diag_ndjson_path);
}
}
+35
View File
@@ -0,0 +1,35 @@
/*
* diagnostics.h — Periodic diagnostics file writer.
*
* When CBM_DIAGNOSTICS=1, writes /tmp/cbm-diagnostics-<pid>.json every 5s.
* Soak tests read this file to track memory, FDs, query stats over time.
*/
#ifndef CBM_DIAGNOSTICS_H
#define CBM_DIAGNOSTICS_H
#include <stdbool.h>
#include <stdint.h>
#include <stdatomic.h>
/* Global query stats — updated by the MCP server on each tool call. */
typedef struct {
atomic_int count; /* total tool calls */
atomic_int errors; /* tool calls that returned isError=true */
atomic_llong time_us; /* cumulative wall-clock time (microseconds) */
atomic_llong max_us; /* max single call time (microseconds) */
} cbm_query_stats_t;
/* Singleton query stats — MCP server increments these. */
extern cbm_query_stats_t g_query_stats;
/* Record a completed tool call. */
void cbm_diag_record_query(long long duration_us, bool is_error);
/* Start the diagnostics writer thread (if CBM_DIAGNOSTICS env is set).
* Call once from main(). Returns true if started. */
bool cbm_diag_start(void);
/* Stop the writer thread and delete the diagnostics file. */
void cbm_diag_stop(void);
#endif /* CBM_DIAGNOSTICS_H */
+40
View File
@@ -0,0 +1,40 @@
/*
* dump_verify.c — Post-dump plausibility gate (#334).
*/
#include "foundation/dump_verify.h"
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/platform.h"
#include <stdlib.h>
#include <string.h>
bool cbm_dump_verify_is_degraded(int committed_nodes, int persisted_nodes, double ratio,
int min_floor) {
if (ratio <= 0.0) {
return false;
}
if (committed_nodes < 0) {
return false;
}
if (committed_nodes <= min_floor) {
return false;
}
if (persisted_nodes < 0) {
return true;
}
return (double)persisted_nodes < (double)committed_nodes * ratio;
}
double cbm_dump_verify_min_ratio(void) {
char buf[CBM_SZ_32];
if (cbm_safe_getenv("CBM_DUMP_VERIFY_MIN_RATIO", buf, sizeof(buf), NULL) != NULL) {
char *end = NULL;
double r = strtod(buf, &end);
if (end != buf && r >= 0.0 && r <= 1.0) {
return r;
}
cbm_log_warn("dump_verify.env.invalid", "value", buf, "fallback", "0.5");
}
return CBM_DUMP_VERIFY_DEFAULT_RATIO;
}
+32
View File
@@ -0,0 +1,32 @@
/*
* dump_verify.h — Post-dump plausibility gate (#334 design b).
*
* Compares committed in-memory node counts against persisted SQLite rows
* after index_repository completes. Nodes-only gate (edges shrink legitimately
* at dump when endpoints fail to resolve).
*/
#ifndef CBM_DUMP_VERIFY_H
#define CBM_DUMP_VERIFY_H
#include <stdbool.h>
/** Repos with at most this many committed nodes skip the ratio gate. */
enum { CBM_DUMP_VERIFY_MIN_FLOOR = 50 };
/** Default minimum persisted/committed ratio when env is unset. */
#define CBM_DUMP_VERIFY_DEFAULT_RATIO 0.5
/**
* True when persisted_nodes is implausibly below committed_nodes.
*
* Returns false when ratio <= 0 (gate disabled), committed_nodes < 0 (no dump),
* committed_nodes <= min_floor (sparse repo), or persisted >= committed * ratio.
* Returns true when persisted_nodes < 0 (count error).
*/
bool cbm_dump_verify_is_degraded(int committed_nodes, int persisted_nodes, double ratio,
int min_floor);
/** Read CBM_DUMP_VERIFY_MIN_RATIO (0..1); invalid/unset -> default 0.5. Set 0 to disable. */
double cbm_dump_verify_min_ratio(void);
#endif /* CBM_DUMP_VERIFY_H */
+105
View File
@@ -0,0 +1,105 @@
/*
* dyn_array.h — Type-safe growable arrays via macros (header-only).
*
* Usage:
* CBM_DYN_ARRAY(int) nums = {0}; // zero-init
* cbm_da_push(&nums, 42);
* cbm_da_push(&nums, 99);
* for (int i = 0; i < nums.count; i++)
* printf("%d\n", nums.items[i]);
* cbm_da_free(&nums);
*
* Design:
* - Items are contiguous in memory (cache-friendly)
* - Grows by 2x (amortized O(1) push)
* - Uses realloc — NOT arena-compatible (use CBMDefArray etc. for arena)
* - Header-only: no .c file needed
*/
#ifndef CBM_DYN_ARRAY_H
#define CBM_DYN_ARRAY_H
#include <stdlib.h>
#include <string.h>
/* Declare a dynamic array type for a given element type. */
#define CBM_DYN_ARRAY(T) \
struct { \
T *items; \
int count; \
int cap; \
}
/* Push an element. Grows by 2x when full. */
#define cbm_da_push(da, item) \
do { \
if ((da)->count >= (da)->cap) { \
int _new_cap = (da)->cap ? (da)->cap * 2 : 8; \
void *_new = realloc((da)->items, (size_t)_new_cap * sizeof(*(da)->items)); \
if (!_new) \
break; \
(da)->items = _new; \
(da)->cap = _new_cap; \
} \
(da)->items[(da)->count++] = (item); \
} while (0)
/* Push an element with a pointer return (for in-place init). */
#define cbm_da_push_ptr(da) \
(((da)->count >= (da)->cap \
? ((void)((da)->cap = (da)->cap ? (da)->cap * 2 : 8), \
(void)((da)->items = realloc((da)->items, (size_t)(da)->cap * sizeof(*(da)->items)))) \
: (void)0), \
&(da)->items[(da)->count++])
/* Pop last element. Returns the element. Undefined if empty. */
#define cbm_da_pop(da) ((da)->items[--(da)->count])
/* Get last element without removing. Undefined if empty. */
#define cbm_da_last(da) ((da)->items[(da)->count - 1])
/* Clear without freeing (reset count to 0). */
#define cbm_da_clear(da) ((da)->count = 0)
/* Free all memory. */
#define cbm_da_free(da) \
do { \
free((da)->items); \
(da)->items = NULL; \
(da)->count = 0; \
(da)->cap = 0; \
} while (0)
/* Reserve capacity (grow if needed, never shrink). */
#define cbm_da_reserve(da, n) \
do { \
if ((n) > (da)->cap) { \
void *_new = realloc((da)->items, (size_t)(n) * sizeof(*(da)->items)); \
if (_new) { \
(da)->items = _new; \
(da)->cap = (n); \
} \
} \
} while (0)
/* Insert at index, shifting elements right. */
#define cbm_da_insert(da, idx, item) \
do { \
cbm_da_push(da, item); /* ensure space */ \
if ((idx) < (da)->count - 1) { \
memmove(&(da)->items[(idx) + 1], &(da)->items[(idx)], \
(size_t)((da)->count - 1 - (idx)) * sizeof(*(da)->items)); \
(da)->items[(idx)] = (item); \
} \
} while (0)
/* Remove at index, shifting elements left. */
#define cbm_da_remove(da, idx) \
do { \
if ((idx) < (da)->count - 1) { \
memmove(&(da)->items[(idx)], &(da)->items[(idx) + 1], \
(size_t)((da)->count - 1 - (idx)) * sizeof(*(da)->items)); \
} \
(da)->count--; \
} while (0)
#endif /* CBM_DYN_ARRAY_H */
+138
View File
@@ -0,0 +1,138 @@
/*
* hash_table.c — CBMHashTable backed by Verstable.
*
* Public API in hash_table.h is unchanged. Internals are a Verstable
* template instantiation (const char* → void*). Verstable is a 2024
* open-addressing hash table using quadratic probing with metadata
* stored separately from buckets (4-bit hash fragment + 11-bit
* displacement + 1-bit in-home-bucket flag per uint16_t). Documented
* in vendored/verstable/verstable.h.
*
* Why swap the prior Robin Hood implementation: cumulative profiling
* showed cbm_ht_get is a hot path in resolve_file_calls's per-call
* registry resolution. Verstable's 4-bit hash-fragment metadata
* sidesteps most key comparisons during chain walks, which the prior
* implementation could not.
*
* Lifetime: keys are BORROWED pointers (caller owns the strings).
* Verstable's KEY_TY is const char*; the templated comparison +
* hash use the standard vt_cmpr_string / vt_hash_string helpers.
*/
#include "foundation/constants.h"
#include "hash_table.h"
#include <stdlib.h>
#include <string.h>
/* Instantiate a Verstable map of (const char* → void*). The single
* include below generates static inline functions named cbm_vt_init,
* cbm_vt_cleanup, cbm_vt_get, cbm_vt_insert, etc., plus the cbm_vt
* struct itself. */
#define NAME cbm_vt
#define KEY_TY const char *
#define VAL_TY void *
#define HASH_FN vt_hash_string
#define CMPR_FN vt_cmpr_string
#include "../../internal/cbm/vendored/verstable/verstable.h"
/* The opaque CBMHashTable struct holds the Verstable instance + a
* count cache (Verstable's _size traversal is O(buckets) so we keep
* our own atomic-free counter). */
struct CBMHashTable {
cbm_vt vt;
};
CBMHashTable *cbm_ht_create(uint32_t initial_capacity) {
CBMHashTable *ht = (CBMHashTable *)calloc(CBM_ALLOC_ONE, sizeof(*ht));
if (!ht)
return NULL;
cbm_vt_init(&ht->vt);
if (initial_capacity > 0) {
/* Reserve enough buckets for the requested entries. Verstable
* computes the minimum bucket count internally. */
if (!cbm_vt_reserve(&ht->vt, (size_t)initial_capacity)) {
cbm_vt_cleanup(&ht->vt);
free(ht);
return NULL;
}
}
return ht;
}
void cbm_ht_free(CBMHashTable *ht) {
if (!ht)
return;
cbm_vt_cleanup(&ht->vt);
free(ht);
}
void *cbm_ht_set(CBMHashTable *ht, const char *key, void *value) {
if (!ht || !key)
return NULL;
/* Capture previous value (if any) before overwriting.
* Verstable's _insert overwrites silently and returns an iterator
* to the (now updated) entry — we have to peek first to surface
* the prior value to the caller (back-compat with our API). */
void *prev = NULL;
cbm_vt_itr itr = cbm_vt_get(&ht->vt, key);
if (!cbm_vt_is_end(itr)) {
prev = itr.data->val;
}
(void)cbm_vt_insert(&ht->vt, key, value);
return prev;
}
void *cbm_ht_get(const CBMHashTable *ht, const char *key) {
if (!ht || !key)
return NULL;
cbm_vt_itr itr = cbm_vt_get(&ht->vt, key);
if (cbm_vt_is_end(itr))
return NULL;
return itr.data->val;
}
bool cbm_ht_has(const CBMHashTable *ht, const char *key) {
if (!ht || !key)
return false;
cbm_vt_itr itr = cbm_vt_get(&ht->vt, key);
return !cbm_vt_is_end(itr);
}
const char *cbm_ht_get_key(const CBMHashTable *ht, const char *key) {
if (!ht || !key)
return NULL;
cbm_vt_itr itr = cbm_vt_get(&ht->vt, key);
if (cbm_vt_is_end(itr))
return NULL;
return itr.data->key;
}
void *cbm_ht_delete(CBMHashTable *ht, const char *key) {
if (!ht || !key)
return NULL;
cbm_vt_itr itr = cbm_vt_get(&ht->vt, key);
if (cbm_vt_is_end(itr))
return NULL;
void *prev = itr.data->val;
(void)cbm_vt_erase(&ht->vt, key);
return prev;
}
uint32_t cbm_ht_count(const CBMHashTable *ht) {
if (!ht)
return 0;
return (uint32_t)cbm_vt_size(&ht->vt);
}
void cbm_ht_foreach(const CBMHashTable *ht, cbm_ht_iter_fn fn, void *userdata) {
if (!ht || !fn)
return;
for (cbm_vt_itr itr = cbm_vt_first(&ht->vt); !cbm_vt_is_end(itr); itr = cbm_vt_next(itr)) {
fn(itr.data->key, itr.data->val, userdata);
}
}
void cbm_ht_clear(CBMHashTable *ht) {
if (!ht)
return;
cbm_vt_clear(&ht->vt);
}
+57
View File
@@ -0,0 +1,57 @@
/*
* hash_table.h — String → void* hash table.
*
* Public API unchanged across implementation rewrites. As of v2 the
* internals are Verstable (https://github.com/JacksonAllan/Verstable),
* a 2024 state-of-the-art open-addressing hash table with quadratic
* probing + per-bucket 4-bit hash fragments. The struct is opaque —
* callers MUST go through cbm_ht_create() and the API functions.
*
* Keys are borrowed pointers — the table does not copy or free them.
* Callers own the key strings for the lifetime of the entry.
*/
#ifndef CBM_HASH_TABLE_H
#define CBM_HASH_TABLE_H
#include <stddef.h>
#include <stdint.h>
#include <stdbool.h>
/* Opaque — full definition lives in hash_table.c. */
typedef struct CBMHashTable CBMHashTable;
/* Create a hash table with initial capacity hint (used to pre-reserve
* buckets and avoid early growth; 0 = library default). */
CBMHashTable *cbm_ht_create(uint32_t initial_capacity);
/* Free the hash table (does NOT free keys or values). */
void cbm_ht_free(CBMHashTable *ht);
/* Insert or update. Returns previous value (NULL if new key). */
void *cbm_ht_set(CBMHashTable *ht, const char *key, void *value);
/* Lookup. Returns NULL if not found. */
void *cbm_ht_get(const CBMHashTable *ht, const char *key);
/* Check if key exists. */
bool cbm_ht_has(const CBMHashTable *ht, const char *key);
/* Return the stored key pointer for a given lookup key, or NULL.
* Useful when you need the canonical (heap-owned) key string
* rather than your own local copy. */
const char *cbm_ht_get_key(const CBMHashTable *ht, const char *key);
/* Delete. Returns removed value (NULL if not found). */
void *cbm_ht_delete(CBMHashTable *ht, const char *key);
/* Number of entries. */
uint32_t cbm_ht_count(const CBMHashTable *ht);
/* Iteration: call fn(key, value, userdata) for each entry. */
typedef void (*cbm_ht_iter_fn)(const char *key, void *value, void *userdata);
void cbm_ht_foreach(const CBMHashTable *ht, cbm_ht_iter_fn fn, void *userdata);
/* Clear all entries (keeps allocated memory). */
void cbm_ht_clear(CBMHashTable *ht);
#endif /* CBM_HASH_TABLE_H */
+56
View File
@@ -0,0 +1,56 @@
/*
* limits.c — Env-configurable safety limits (Stage 2 / Track B4).
*/
#include "foundation/limits.h"
#include <errno.h>
#include <limits.h>
#include <stdlib.h>
long cbm_max_file_bytes(void) {
/* 512 MiB — generous: real source files never approach it, but a
* pathological / vendored blob degrades to a reported "oversized" skip
* instead of a silent drop or an unbounded read. */
const long default_cap = 512L * 1024 * 1024;
const char *raw = getenv("CBM_MAX_FILE_BYTES");
if (raw && raw[0]) {
errno = 0;
char *end = NULL;
long v = strtol(raw, &end, 10);
if (errno == 0 && end != raw && *end == '\0' && v > 0) {
return v;
}
/* Unparseable / non-positive → fall through to the safe default. */
}
return default_cap;
}
/* Shared env-int parser: a positive integer in [1, INT_MAX], else the fallback.
* Read fresh each call (see cbm_max_file_bytes rationale — cheap, test-friendly,
* no stale memoized copy across runs). */
static int env_positive_int(const char *name, int fallback) {
const char *raw = getenv(name);
if (raw && raw[0]) {
errno = 0;
char *end = NULL;
long v = strtol(raw, &end, 10);
if (errno == 0 && end != raw && *end == '\0' && v > 0 && v <= INT_MAX) {
return (int)v;
}
/* Unparseable / non-positive / out-of-range → safe default. */
}
return fallback;
}
int cbm_cypher_max_depth(void) {
/* 10 — generous for a code call/def graph; an explicit `*1..N` above this is
* WARN-capped, never an unbounded (cyclic-graph DoS) traversal. */
return env_positive_int("CBM_CYPHER_MAX_DEPTH", 10);
}
int cbm_mcp_max_depth(void) {
/* 15 — ceiling for client-driven MCP graph traversals (trace_call_path,
* detect_changes); the caller's `depth` is WARN-clamped to this. */
return env_positive_int("CBM_MCP_MAX_DEPTH", 15);
}
+46
View File
@@ -0,0 +1,46 @@
/*
* limits.h — Generous, env-configurable safety limits (Stage 2 / Track B4).
*
* Each knob has a generous default. Hitting a limit degrades to a *reported*
* skip (surfaced via MCP/CLI/logfile), never a silent drop and never an
* unbounded read (unbounded just trades a crash for an OOM/hang). Every limit
* is env-overridable so an operator can tune it per-repo without a rebuild.
*/
#ifndef CBM_LIMITS_H
#define CBM_LIMITS_H
/* Result of an attempted per-file read, so callers can attribute a skip to the
* right phase/reason instead of collapsing every failure into "read failed". */
typedef enum {
CBM_READ_OK = 0, /* file read successfully */
CBM_READ_OPEN_FAIL, /* could not open (missing / permission) */
CBM_READ_EMPTY, /* zero/negative size — benign, nothing to index */
CBM_READ_OVERSIZED, /* size exceeds cbm_max_file_bytes() */
CBM_READ_OOM, /* buffer allocation failed */
} cbm_read_status_t;
/* Maximum size (bytes) of a single source file the indexer will read into
* memory. Files larger than this are skipped-and-reported (phase "oversized"),
* never silently dropped. Override with CBM_MAX_FILE_BYTES (a positive integer
* count of bytes). Default 512 MiB (raised from the historical 100 MB cap).
*
* The env var is read on each call — this is intentional: read_file() calls it
* once per file (negligible), and reading fresh means a test / operator can
* change the cap via setenv without a process restart or a stale memoized copy
* leaking across runs. */
long cbm_max_file_bytes(void);
/* Maximum variable-length path depth for the Cypher engine (the `*min..max`
* hop ceiling). BOTH the explicit (`*1..N`) and unbounded (`*`, `*..m`) forms
* are clamped to this, so `[:CALLS*1..1000000]` degrades to a WARN-and-cap
* rather than an unbounded (cyclic-graph DoS) traversal. Override with
* CBM_CYPHER_MAX_DEPTH (a positive integer). Default 10. */
int cbm_cypher_max_depth(void);
/* Maximum traversal depth for client-driven MCP graph tools (trace_call_path,
* detect_changes): the client `depth` argument is WARN-clamped to this so an
* arbitrarily large value cannot drive an unbounded BFS over the shared store.
* Override with CBM_MCP_MAX_DEPTH (a positive integer). Default 15. */
int cbm_mcp_max_depth(void);
#endif /* CBM_LIMITS_H */
+318
View File
@@ -0,0 +1,318 @@
/*
* log.c — Structured key-value logging to stderr.
*/
#include "log.h"
#include "foundation/constants.h"
#include <ctype.h>
#include <inttypes.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static CBMLogLevel g_log_level = CBM_LOG_INFO;
static CBMLogFormat g_log_format = CBM_LOG_FORMAT_TEXT;
static cbm_log_sink_fn g_log_sink = NULL;
static CBMLogSinkMode g_log_sink_mode = CBM_LOG_SINK_REPLACE;
/* CBM_LOG_LEVEL support — distilled from #414 (closes #413, thanks @santanusinha). */
void cbm_log_init_from_env(void) {
/* getenv() is safe here: this runs at startup before any thread is created,
* so there is no concurrent setenv() to race against. */
const char *raw = getenv("CBM_LOG_LEVEL");
if (raw && raw[0] != '\0') {
/* Textual form, case-insensitive. Index of each name == its enum value. */
static const char *const names[] = {"debug", "info", "warn", "error", "none"};
char lower[8];
size_t i = 0;
for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
lower[i] = (char)tolower((unsigned char)raw[i]);
}
lower[i] = '\0';
if (raw[i] == '\0') { /* fully consumed — candidate textual match */
for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
if (strcmp(lower, names[lvl]) == 0) {
cbm_log_set_level((CBMLogLevel)lvl);
goto parse_format;
}
}
}
/* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
char *end = NULL;
long n = strtol(raw, &end, CBM_DECIMAL_BASE);
if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
cbm_log_set_level((CBMLogLevel)n);
}
}
/* Unrecognised value: leave the level unchanged (fail-open). */
parse_format:;
const char *fmt = getenv("CBM_LOG_FORMAT");
if (fmt && fmt[0] != '\0') {
char lower_fmt[8];
size_t i = 0;
for (; i < sizeof(lower_fmt) - 1 && fmt[i] != '\0'; i++) {
lower_fmt[i] = (char)tolower((unsigned char)fmt[i]);
}
lower_fmt[i] = '\0';
if (fmt[i] == '\0' && strcmp(lower_fmt, "json") == 0) {
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
} else if (fmt[i] == '\0' && strcmp(lower_fmt, "text") == 0) {
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
}
return;
}
/* Format is intentionally explicit-only. Logs stay local to stderr and the
* optional in-process sink; deployment environment variables must not
* silently change the operator-selected output shape. */
}
void cbm_log_set_sink(cbm_log_sink_fn fn) {
cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
}
void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode) {
g_log_sink = fn;
g_log_sink_mode = mode;
}
void cbm_log_set_level(CBMLogLevel level) {
g_log_level = level;
}
CBMLogLevel cbm_log_get_level(void) {
return g_log_level;
}
void cbm_log_set_format(CBMLogFormat format) {
g_log_format = format;
}
CBMLogFormat cbm_log_get_format(void) {
return g_log_format;
}
static const char *level_str(CBMLogLevel level) {
switch (level) {
case CBM_LOG_DEBUG:
return "debug";
case CBM_LOG_INFO:
return "info";
case CBM_LOG_WARN:
return "warn";
case CBM_LOG_ERROR:
return "error";
default:
return "unknown";
}
}
static void append_char(char *buf, size_t bufsz, size_t *pos, char ch) {
if (*pos < bufsz - 1) {
buf[*pos] = ch;
}
(*pos)++;
}
static void append_raw(char *buf, size_t bufsz, size_t *pos, const char *s) {
if (!s) {
return;
}
while (*s) {
append_char(buf, bufsz, pos, *s++);
}
}
static void append_text_atom(char *buf, size_t bufsz, size_t *pos, const char *s) {
if (!s) {
return;
}
while (*s) {
unsigned char ch = (unsigned char)*s++;
if (ch <= ' ' || ch == 0x7f) {
append_char(buf, bufsz, pos, '_');
} else {
append_char(buf, bufsz, pos, (char)ch);
}
}
}
static void append_json_string(char *buf, size_t bufsz, size_t *pos, const char *s) {
append_char(buf, bufsz, pos, '"');
if (s) {
while (*s) {
unsigned char ch = (unsigned char)*s++;
switch (ch) {
case '"':
append_raw(buf, bufsz, pos, "\\\"");
break;
case '\\':
append_raw(buf, bufsz, pos, "\\\\");
break;
case '\b':
append_raw(buf, bufsz, pos, "\\b");
break;
case '\f':
append_raw(buf, bufsz, pos, "\\f");
break;
case '\n':
append_raw(buf, bufsz, pos, "\\n");
break;
case '\r':
append_raw(buf, bufsz, pos, "\\r");
break;
case '\t':
append_raw(buf, bufsz, pos, "\\t");
break;
default:
if (ch < 0x20) {
static const char hex[] = "0123456789abcdef";
append_raw(buf, bufsz, pos, "\\u00");
append_char(buf, bufsz, pos, hex[ch >> 4]);
append_char(buf, bufsz, pos, hex[ch & 0xf]);
} else {
append_char(buf, bufsz, pos, (char)ch);
}
break;
}
}
}
append_char(buf, bufsz, pos, '"');
}
static void finish_line(char *buf, size_t bufsz, size_t pos) {
if (bufsz == 0) {
return;
}
if (pos >= bufsz) {
buf[bufsz - 1] = '\0';
} else {
buf[pos] = '\0';
}
}
static void emit_line(const char *line) {
if (g_log_sink) {
g_log_sink(line);
if (g_log_sink_mode == CBM_LOG_SINK_REPLACE) {
return;
}
}
(void)fprintf(stderr, "%s\n", line);
}
void cbm_log(CBMLogLevel level, const char *msg, ...) {
if (level < g_log_level) {
return;
}
char line_buf[CBM_SZ_4K];
size_t pos = 0;
va_list args;
va_start(args, msg);
if (g_log_format == CBM_LOG_FORMAT_JSON) {
append_raw(line_buf, sizeof(line_buf), &pos, "{\"level\":");
append_json_string(line_buf, sizeof(line_buf), &pos, level_str(level));
append_raw(line_buf, sizeof(line_buf), &pos, ",\"event\":");
append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
for (;;) {
const char *key = va_arg(args, const char *);
if (!key) {
break;
}
const char *val = va_arg(args, const char *);
append_char(line_buf, sizeof(line_buf), &pos, ',');
append_json_string(line_buf, sizeof(line_buf), &pos, key);
append_char(line_buf, sizeof(line_buf), &pos, ':');
append_json_string(line_buf, sizeof(line_buf), &pos, val ? val : "");
}
append_char(line_buf, sizeof(line_buf), &pos, '}');
} else {
append_raw(line_buf, sizeof(line_buf), &pos, "level=");
append_text_atom(line_buf, sizeof(line_buf), &pos, level_str(level));
append_raw(line_buf, sizeof(line_buf), &pos, " msg=");
append_text_atom(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
for (;;) {
const char *key = va_arg(args, const char *);
if (!key) {
break;
}
const char *val = va_arg(args, const char *);
append_char(line_buf, sizeof(line_buf), &pos, ' ');
append_text_atom(line_buf, sizeof(line_buf), &pos, key);
append_char(line_buf, sizeof(line_buf), &pos, '=');
append_text_atom(line_buf, sizeof(line_buf), &pos, val ? val : "");
}
}
va_end(args);
finish_line(line_buf, sizeof(line_buf), pos);
emit_line(line_buf);
}
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value) {
char value_buf[CBM_SZ_32];
snprintf(value_buf, sizeof(value_buf), "%" PRId64, value);
cbm_log(level, msg, key ? key : "?", value_buf, NULL);
}
static void copy_path_without_query(const char *path, char *out, size_t outsz) {
if (!out || outsz == 0) {
return;
}
out[0] = '\0';
if (!path) {
return;
}
size_t n = 0;
while (path[n] && path[n] != '?' && path[n] != '#' && n < outsz - 1) {
out[n] = path[n];
n++;
}
out[n] = '\0';
}
void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
int64_t duration_us) {
char duration_ms[CBM_SZ_32];
snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000);
if (tool_name && tool_name[0] != '\0') {
cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
"method", method ? method : "", "tool", tool_name, "status",
is_error ? "error" : "ok", "duration_ms", duration_ms, NULL);
} else {
cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
"method", method ? method : "", "status", is_error ? "error" : "ok", "duration_ms",
duration_ms, NULL);
}
}
void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
int64_t duration_ms, size_t request_bytes, size_t response_bytes) {
char safe_path[CBM_SZ_1K];
char status_buf[CBM_SZ_16];
char duration_buf[CBM_SZ_32];
char request_buf[CBM_SZ_32];
char response_buf[CBM_SZ_32];
copy_path_without_query(path, safe_path, sizeof(safe_path));
snprintf(status_buf, sizeof(status_buf), "%d", status);
snprintf(duration_buf, sizeof(duration_buf), "%" PRId64, duration_ms);
snprintf(request_buf, sizeof(request_buf), "%zu", request_bytes);
snprintf(response_buf, sizeof(response_buf), "%zu", response_bytes);
CBMLogLevel level = CBM_LOG_INFO;
if (status >= 500) {
level = CBM_LOG_ERROR;
} else if (status >= 400) {
level = CBM_LOG_WARN;
}
cbm_log(level, "http.request", "component", component ? component : "", "method",
method ? method : "", "path", safe_path, "status", status_buf, "duration_ms",
duration_buf, "request_bytes", request_buf, "response_bytes", response_buf, NULL);
}
+91
View File
@@ -0,0 +1,91 @@
/*
* log.h — Structured key-value logging to stderr.
*
* Design:
* - All output goes to stderr (stdout is reserved for MCP JSON-RPC)
* - Structured text format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
* - Optional JSON format for local structured parsing
* - Levels: DEBUG, INFO, WARN, ERROR
* - Level filtering at runtime via cbm_log_set_level() or the
* CBM_LOG_LEVEL env var (see cbm_log_init_from_env)
* - Thread-safe (each fprintf is atomic on POSIX for lines < PIPE_BUF)
*/
#ifndef CBM_LOG_H
#define CBM_LOG_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
typedef enum {
CBM_LOG_DEBUG = 0,
CBM_LOG_INFO = 1,
CBM_LOG_WARN = 2,
CBM_LOG_ERROR = 3,
CBM_LOG_NONE = 4 /* disable all logging */
} CBMLogLevel;
typedef enum {
CBM_LOG_FORMAT_TEXT = 0,
CBM_LOG_FORMAT_JSON = 1,
} CBMLogFormat;
typedef enum {
CBM_LOG_SINK_REPLACE = 0,
CBM_LOG_SINK_TEE = 1,
} CBMLogSinkMode;
/* Apply the CBM_LOG_LEVEL environment variable to the runtime log level.
* Accepts (case-insensitive) "debug", "info", "warn", "error", "none", or
* the numeric equivalents 0..4 matching CBMLogLevel. Unknown, empty, or
* unset values leave the level unchanged (fail-open).
*
* Also applies CBM_LOG_FORMAT=text|json. If unset, the current format is left
* unchanged. Call once at startup before any threads or log lines. */
void cbm_log_init_from_env(void);
/* Set minimum log level (default: INFO). */
void cbm_log_set_level(CBMLogLevel level);
/* Get current log level. */
CBMLogLevel cbm_log_get_level(void);
/* Set/get output format. Default is text. */
void cbm_log_set_format(CBMLogFormat format);
CBMLogFormat cbm_log_get_format(void);
/* Core logging function. msg is a short semantic tag.
* Variadic args are key-value pairs: (const char *key, const char *value)...
* Terminated by NULL key.
*
* Example:
* cbm_log(CBM_LOG_INFO, "pass.timing",
* "pass", "defs", "elapsed_ms", "42", NULL);
*
* Output:
* level=info msg=pass.timing pass=defs elapsed_ms=42
*/
void cbm_log(CBMLogLevel level, const char *msg, ...);
/* Convenience macros. */
#define cbm_log_debug(msg, ...) cbm_log(CBM_LOG_DEBUG, msg, ##__VA_ARGS__, NULL)
#define cbm_log_info(msg, ...) cbm_log(CBM_LOG_INFO, msg, ##__VA_ARGS__, NULL)
#define cbm_log_warn(msg, ...) cbm_log(CBM_LOG_WARN, msg, ##__VA_ARGS__, NULL)
#define cbm_log_error(msg, ...) cbm_log(CBM_LOG_ERROR, msg, ##__VA_ARGS__, NULL)
/* Log with integer value (avoids sprintf for common case). */
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value);
/* Operational event helpers. They deliberately avoid request bodies, headers,
* arguments, and query strings. */
void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
int64_t duration_us);
void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
int64_t duration_ms, size_t request_bytes, size_t response_bytes);
/* Optional log sink callback — called with the formatted log line. */
typedef void (*cbm_log_sink_fn)(const char *line);
void cbm_log_set_sink(cbm_log_sink_fn fn);
void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode);
#endif /* CBM_LOG_H */
+313
View File
@@ -0,0 +1,313 @@
/*
* mem.c — Unified memory management via mimalloc.
*
* Budget tracking based on actual RSS via mi_process_info().
* When MI_OVERRIDE=0 (ASan builds), falls back to OS-specific
* RSS queries (task_info on macOS, /proc/self/statm on Linux,
* GetProcessMemoryInfo on Windows).
*/
#include "mem.h"
#include "platform.h"
#include "log.h"
#include "foundation/constants.h"
#define MAX_RAM_FRACTION 1.0
#define DEFAULT_RAM_FRACTION 0.5
#include <errno.h>
#include <mimalloc.h>
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <psapi.h>
#elif defined(__APPLE__)
#include <mach/mach.h>
#else
#include <unistd.h>
#endif
/* ── Static state ─────────────────────────────────────────────── */
static size_t g_budget; /* budget in bytes */
static atomic_int g_initialized; /* init guard */
static atomic_int g_was_over; /* pressure hysteresis */
#define MB_DIVISOR ((size_t)(CBM_SZ_1K * CBM_SZ_1K))
/* ── OS fallback for RSS (ASan builds where MI_OVERRIDE=0) ──── */
static size_t os_rss(void) {
#ifdef _WIN32
PROCESS_MEMORY_COUNTERS pmc;
if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) {
return (size_t)pmc.WorkingSetSize;
}
return 0;
#elif defined(__APPLE__)
struct mach_task_basic_info info = {0};
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &count) ==
KERN_SUCCESS) {
return (size_t)info.resident_size;
}
return 0;
#else
FILE *f = fopen("/proc/self/statm", "r");
if (!f) {
return 0;
}
unsigned long pages = 0;
unsigned long rss_pages = 0;
if (fscanf(f, "%lu %lu", &pages, &rss_pages) != 2) {
rss_pages = 0;
}
(void)fclose(f);
long ps = sysconf(_SC_PAGESIZE);
return rss_pages * (ps > 0 ? (size_t)ps : CBM_SZ_4K);
#endif
}
/* ── Pressure logging (hysteresis) ────────────────────────────── */
static void check_pressure(size_t rss) {
if (g_budget == 0) {
return;
}
bool over = rss > g_budget;
int was = atomic_load(&g_was_over);
if (over && !was) {
atomic_store(&g_was_over, 1);
char rss_mb[CBM_SZ_32];
char budget_mb[CBM_SZ_32];
char pct_str[CBM_SZ_16];
snprintf(rss_mb, sizeof(rss_mb), "%zu", rss / MB_DIVISOR);
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
snprintf(pct_str, sizeof(pct_str), "%zu",
g_budget > 0 ? (rss * CBM_PERCENT) / g_budget : 0);
cbm_log_warn("mem.pressure.warn", "rss_mb", rss_mb, "budget_mb", budget_mb, "pct", pct_str);
} else if (!over && was) {
atomic_store(&g_was_over, 0);
char rss_mb[CBM_SZ_32];
char budget_mb[CBM_SZ_32];
char pct_str[CBM_SZ_16];
snprintf(rss_mb, sizeof(rss_mb), "%zu", rss / MB_DIVISOR);
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
snprintf(pct_str, sizeof(pct_str), "%zu",
g_budget > 0 ? (rss * CBM_PERCENT) / g_budget : 0);
cbm_log_info("mem.pressure.ok", "rss_mb", rss_mb, "budget_mb", budget_mb, "pct", pct_str);
}
}
/* ── Public API ────────────────────────────────────────────────── */
#define RAM_FRACTION_DEFAULT 0.5
#define RAM_FRACTION_16GB 0.25
#define RAM_FRACTION_32GB 0.35
#define RAM_BYTES_PER_GB (1024ULL * 1024 * 1024)
double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes) {
if (total_ram_bytes <= 16ULL * RAM_BYTES_PER_GB) {
return RAM_FRACTION_16GB;
}
if (total_ram_bytes <= 32ULL * RAM_BYTES_PER_GB) {
return RAM_FRACTION_32GB;
}
return RAM_FRACTION_DEFAULT;
}
cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
const char *budget_mb) {
if (ram_fraction <= 0.0 || ram_fraction > MAX_RAM_FRACTION) {
ram_fraction = DEFAULT_RAM_FRACTION;
}
cbm_mem_budget_t result = {
.budget = (size_t)((double)total_ram * ram_fraction),
.source = "ram_fraction",
.clamped = false,
.invalid = false,
};
if (budget_mb == NULL || budget_mb[0] == '\0') {
return result; /* no override → fraction-derived budget */
}
/* Strict parse, matching the src/foundation/limits.c convention: reject
* trailing garbage (`*end`), overflow (errno==ERANGE), and non-positive
* values. This turns a fat-fingered value (e.g. "8GB", or a 20-digit typo)
* into a clean fallback-with-warning rather than a silently wrong budget. */
errno = 0;
char *end = NULL;
long long want_mb = strtoll(budget_mb, &end, CBM_DECIMAL_BASE);
if (errno != 0 || end == budget_mb || *end != '\0' || want_mb <= 0) {
result.invalid = true; /* keep the fraction-derived budget */
return result;
}
result.source = "CBM_MEM_BUDGET_MB";
size_t want = (size_t)want_mb;
if (total_ram > 0) {
/* Compare in MiB space so a valid-but-huge request (e.g. 2^44 MiB, which
* would overflow the ×MiB byte multiply) clamps cleanly to total_ram. */
if (want > total_ram / MB_DIVISOR) {
result.budget = total_ram;
result.clamped = true;
} else {
result.budget = want * MB_DIVISOR;
}
} else if (want > SIZE_MAX / MB_DIVISOR) {
/* RAM detection failed (no clamp target) and the request is
* astronomically large — cap at SIZE_MAX rather than wrap. */
result.budget = SIZE_MAX;
} else {
result.budget = want * MB_DIVISOR;
}
return result;
}
void cbm_mem_init(double ram_fraction) {
int expected = 0;
if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) {
return;
}
if (ram_fraction <= 0.0 || ram_fraction > MAX_RAM_FRACTION) {
ram_fraction = DEFAULT_RAM_FRACTION;
}
/* Reduce upfront memory: don't eagerly commit arenas.
* Force decommit on purge (MADV_FREE_REUSABLE on macOS) so RSS
* drops immediately instead of staying high until memory pressure. */
mi_option_set(mi_option_arena_eager_commit, 0);
mi_option_set(mi_option_purge_decommits, SKIP_ONE);
mi_option_set(mi_option_purge_delay, 0); /* immediate purge, no 1s delay */
/* v3 (#832): reclaim abandoned pages on ANY thread's free (=1), restoring the
* v2 behaviour. mimalloc v3 defaults page_reclaim_on_free=0, so pages a worker
* thread abandons at exit are NOT reclaimed when the main thread later frees
* their blocks (and mi_collect cannot touch abandoned pages) — RSS then
* ratchets across repeated in-process index cycles. The supervised subprocess
* is the primary cure (the child returns 100% RSS on exit); this is the
* in-process fallback for any path that stays in-process (kill switch,
* spawn-fail degrade, embedders). */
mi_option_set(mi_option_page_reclaim_on_free, 1);
/* CBM_MEM_BUDGET_MB env override (memory analogue of CBM_WORKERS).
* Lets users cap the budget directly without an enclosing cgroup —
* useful on bare-metal hosts where cgroup memory limits are absent
* (#363). Explicit override > implicit RAM/cgroup detection. The budget
* math (fraction default, override, clamp-to-total) lives in the pure,
* testable cbm_mem_resolve_budget(); this path only reads the env and
* emits the log/warn lines. */
cbm_system_info_t info = cbm_system_info();
char env_buf[CBM_SZ_32];
const char *env = cbm_safe_getenv("CBM_MEM_BUDGET_MB", env_buf, sizeof(env_buf), NULL);
cbm_mem_budget_t resolved = cbm_mem_resolve_budget(info.total_ram, ram_fraction, env);
g_budget = resolved.budget;
/* The resolver is the single source of truth for the parse + clamp; this
* path only surfaces its outcome as log lines. */
if (resolved.invalid) {
cbm_log_warn("mem.budget.env.invalid", "value", env, "fallback", "ram_fraction");
} else if (resolved.clamped) {
char cap_mb[CBM_SZ_32];
snprintf(cap_mb, sizeof(cap_mb), "%zu", info.total_ram / MB_DIVISOR);
cbm_log_warn("mem.budget.clamped", "requested_mb", env, "cap_mb", cap_mb);
}
char budget_mb[CBM_SZ_32];
char ram_mb[CBM_SZ_32];
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
snprintf(ram_mb, sizeof(ram_mb), "%zu", info.total_ram / MB_DIVISOR);
cbm_log_info("mem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb, "source",
resolved.source);
}
size_t cbm_mem_rss(void) {
#if defined(__linux__)
/* Linux: mimalloc's _mi_prim_process_info() (vendored/mimalloc/src/prim/
* unix/prim.c) never sets pinfo->current_rss on Linux — it only sets
* peak_rss (from getrusage's ru_maxrss). current_rss therefore keeps
* mi_process_info()'s default of pinfo.current_commit: mimalloc's OWN
* committed-page counter, which this project deliberately tunes low via
* mi_option_arena_eager_commit=0 + purge_decommits=1 + purge_delay=0
* (cbm_mem_init) to reduce upfront memory. So on Linux "current_rss" is a
* low-biased mimalloc-internal metric, not true RSS: under concurrent
* large-file parsing it can read a few MB while real RSS is multiple GB,
* silently blinding cbm_mem_over_budget()'s backpressure to real memory
* pressure (small-but-nonzero, so the `current_rss > 0` guard below never
* catches it). os_rss() reads /proc/self/statm — authoritative OS RSS,
* unaffected by mimalloc's accounting — so it is the PRIMARY source on
* Linux, not a last-resort fallback. macOS/Windows are unaffected:
* mimalloc sets current_rss correctly there via task_info /
* GetProcessMemoryInfo. */
size_t proc_rss = os_rss();
if (proc_rss > 0) {
return proc_rss;
}
/* Extremely unlikely (/proc unavailable) — fall through to mimalloc. */
#endif
size_t current_rss = 0;
size_t peak_rss = 0;
mi_process_info(NULL, NULL, NULL, &current_rss, &peak_rss, NULL, NULL, NULL);
if (current_rss > 0) {
return current_rss;
}
/* Fallback for ASan builds (MI_OVERRIDE=0) and any platform where
* mimalloc's current_rss is unavailable/zero. */
return os_rss();
}
size_t cbm_mem_peak_rss(void) {
size_t peak_rss = 0;
mi_process_info(NULL, NULL, NULL, NULL, &peak_rss, NULL, NULL, NULL);
/* Peak RSS is by definition >= current RSS. On Linux cbm_mem_rss() reads the
* live /proc/self/statm value (page-granular), while mimalloc's peak_rss
* comes from getrusage's ru_maxrss (KB-granular, and it can lag the live
* statm read by a few pages). Reading the two sources independently lets a
* fresh current read momentarily exceed the reported peak, breaking the
* peak >= current invariant. Reconcile them: the true peak is at least the
* current RSS. (Not observable on macOS, where both come from mimalloc.) */
size_t current = cbm_mem_rss();
if (current > peak_rss) {
peak_rss = current;
}
if (peak_rss > 0) {
return peak_rss;
}
/* No OS fallback for peak — return current as best approximation */
return os_rss();
}
size_t cbm_mem_budget(void) {
return g_budget;
}
void cbm_mem_set_budget_for_tests(size_t bytes) {
g_budget = bytes;
}
bool cbm_mem_over_budget(void) {
size_t rss = cbm_mem_rss();
check_pressure(rss);
return rss > g_budget;
}
size_t cbm_mem_worker_budget(int num_workers) {
if (num_workers <= 0) {
num_workers = SKIP_ONE;
}
return g_budget / (size_t)num_workers;
}
void cbm_mem_collect(void) {
mi_collect(true);
}
+71
View File
@@ -0,0 +1,71 @@
/*
* mem.h — Unified memory management via mimalloc.
*
* Provides budget tracking based on actual RSS (not partial vmem tracking).
* Uses mi_process_info() as the single source of truth for memory pressure.
* Replaces the old vmem.h budget-tracked virtual memory allocator.
*/
#ifndef CBM_MEM_H
#define CBM_MEM_H
#include <stdbool.h>
#include <stddef.h>
/* Tiered default fraction for MCP startup: 25% on <=16GB, 35% on <=32GB, else 50%. */
double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes);
/* Initialize memory budget = ram_fraction * total_physical_ram.
* The CBM_MEM_BUDGET_MB env var, when set to a positive integer, overrides
* this with an explicit budget in MiB (clamped to physical/cgroup RAM).
* Thread-safe: only the first call takes effect.
* Configures mimalloc options for reduced upfront memory. */
void cbm_mem_init(double ram_fraction);
/* Result of cbm_mem_resolve_budget: the resolved budget plus the metadata
* cbm_mem_init logs — so the parse/clamp logic lives in exactly ONE place and
* the caller never re-parses the env string. */
typedef struct {
size_t budget; /* resolved budget in bytes */
const char *source; /* log token: "ram_fraction" | "CBM_MEM_BUDGET_MB" */
bool clamped; /* override was valid but exceeded total_ram → clamped down */
bool invalid; /* override was present but unparseable / out-of-range / ≤0 */
} cbm_mem_budget_t;
/* Pure budget resolver shared by cbm_mem_init (exposed for testing).
* Returns ram_fraction * total_ram, unless `budget_mb` is a STRICTLY valid
* positive integer string (the CBM_MEM_BUDGET_MB override) — then it returns
* that many MiB, clamped to total_ram when total_ram > 0. Trailing garbage,
* overflow (ERANGE), and non-positive values are rejected (invalid=true) and
* fall back to the fraction-derived value. Reads no globals/env. */
cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
const char *budget_mb);
/* Current RSS in bytes via mi_process_info().
* Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */
size_t cbm_mem_rss(void);
/* Peak RSS in bytes. */
size_t cbm_mem_peak_rss(void);
/* Total budget in bytes. */
size_t cbm_mem_budget(void);
/* TEST HOOK: overwrite the budget directly, bypassing cbm_mem_init's
* init-once guard (a setenv+re-init dance in tests is a silent no-op once
* some earlier init won the guard — the poisoned budget then leaks into
* every later budget consumer in the process). Does NOT flip the init
* guard: a later cbm_mem_init still initializes normally. Callers must
* save cbm_mem_budget() first and restore it before their assertions.
* Never call from production code. */
void cbm_mem_set_budget_for_tests(size_t bytes);
/* Returns true if current RSS exceeds the budget. */
bool cbm_mem_over_budget(void);
/* Per-worker budget hint: budget / num_workers. */
size_t cbm_mem_worker_budget(int num_workers);
/* Return unused pages to the OS. Call between files to bound per-file peak. */
void cbm_mem_collect(void);
#endif /* CBM_MEM_H */
+455
View File
@@ -0,0 +1,455 @@
/*
* platform.c — OS abstraction implementations.
*
* macOS, Linux, and Windows. Platform-specific code behind #ifdef guards.
*/
#include "platform.h"
#include "foundation/constants.h"
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* Canonicalize a Windows drive letter to upper-case in place: "c:/x" -> "C:/x".
* Windows drive letters are case-insensitive, but a lowercase one (as agent
* CWDs often report, e.g. Claude Code's "c:\...") otherwise produces a distinct
* project key ("c-..." vs "C-...") and, on a case-insensitive FS, a colliding
* cache file that clobbers the good index (#227/#367/#394). Folding to a single
* canonical form here — at the one path-normalization choke point — keeps the
* project key, cache file and integrity check consistent regardless of case.
* Only the strict drive-root form `X:/` or bare `X:` is touched, so ordinary
* POSIX paths (which never start that way) are unaffected. */
static void cbm_canonicalize_drive(char *path) {
if (path && path[0] >= 'a' && path[0] <= 'z' && path[1] == ':' &&
(path[2] == '/' || path[2] == '\0')) {
path[0] = (char)(path[0] - 'a' + 'A');
}
}
#ifdef _WIN32
/* ── Windows implementation ────────────────────────────────── */
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <io.h>
#include <sys/stat.h>
#include "foundation/win_utf8.h"
void *cbm_mmap_read(const char *path, size_t *out_size) {
if (!path || !out_size) {
return NULL;
}
*out_size = 0;
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return NULL;
}
HANDLE file = CreateFileW(wpath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, NULL);
if (file == INVALID_HANDLE_VALUE) {
free(wpath);
return NULL;
}
LARGE_INTEGER sz;
if (!GetFileSizeEx(file, &sz) || sz.QuadPart == 0) {
CloseHandle(file);
free(wpath);
return NULL;
}
HANDLE mapping = CreateFileMappingW(file, NULL, PAGE_READONLY, 0, 0, NULL);
if (!mapping) {
CloseHandle(file);
free(wpath);
return NULL;
}
void *addr = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0);
CloseHandle(mapping);
CloseHandle(file);
free(wpath);
if (!addr) {
return NULL;
}
*out_size = (size_t)sz.QuadPart;
return addr;
}
void cbm_munmap(void *addr, size_t size) {
(void)size;
if (addr) {
UnmapViewOfFile(addr);
}
}
uint64_t cbm_now_ns(void) {
LARGE_INTEGER freq, count;
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&count);
return (uint64_t)count.QuadPart * 1000000000ULL / (uint64_t)freq.QuadPart;
}
#define CBM_USEC_PER_SEC 1000000ULL
uint64_t cbm_now_ms(void) {
return cbm_now_ns() / CBM_USEC_PER_SEC;
}
int cbm_nprocs(void) {
SYSTEM_INFO si;
GetSystemInfo(&si);
return (int)si.dwNumberOfProcessors > 0 ? (int)si.dwNumberOfProcessors : 1;
}
bool cbm_file_exists(const char *path) {
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return false;
}
DWORD attr = GetFileAttributesW(wpath);
free(wpath);
return attr != INVALID_FILE_ATTRIBUTES;
}
bool cbm_is_dir(const char *path) {
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return false;
}
DWORD attr = GetFileAttributesW(wpath);
free(wpath);
return attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY);
}
int64_t cbm_file_size(const char *path) {
wchar_t *wpath = cbm_utf8_to_wide(path);
if (!wpath) {
return CBM_NOT_FOUND;
}
WIN32_FILE_ATTRIBUTE_DATA fad;
BOOL ok = GetFileAttributesExW(wpath, GetFileExInfoStandard, &fad);
free(wpath);
if (!ok) {
return CBM_NOT_FOUND;
}
LARGE_INTEGER sz;
sz.HighPart = (LONG)fad.nFileSizeHigh; // cppcheck-suppress unreadVariable
sz.LowPart = fad.nFileSizeLow; // cppcheck-suppress unreadVariable
return (int64_t)sz.QuadPart;
}
char *cbm_normalize_path_sep(char *path) {
if (path) {
for (char *p = path; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
cbm_canonicalize_drive(path);
}
return path;
}
#else /* POSIX (macOS + Linux) */
/* ── POSIX implementation ────────────────────────────────── */
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <time.h>
#ifdef __APPLE__
#include <mach/mach_time.h>
#include <sys/sysctl.h>
#else
#include <sched.h>
#endif
/* ── Memory mapping ──────────────────────────── */
void *cbm_mmap_read(const char *path, size_t *out_size) {
if (!path || !out_size) {
return NULL;
}
*out_size = 0;
int fd = open(path, O_RDONLY);
if (fd < 0) {
return NULL;
}
struct stat st;
if (fstat(fd, &st) != 0 || st.st_size == 0) {
close(fd);
return NULL;
}
void *addr = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (addr == MAP_FAILED) {
return NULL;
}
*out_size = (size_t)st.st_size;
return addr;
}
void cbm_munmap(void *addr, size_t size) {
if (addr && size > 0) {
munmap(addr, size);
}
}
/* ── Timing ───────────────────────────── */
#ifdef __APPLE__
static mach_timebase_info_data_t timebase_info;
static int timebase_init = 0;
uint64_t cbm_now_ns(void) {
if (!timebase_init) {
mach_timebase_info(&timebase_info);
timebase_init = SKIP_ONE;
}
uint64_t ticks = mach_absolute_time();
return ticks * timebase_info.numer / timebase_info.denom;
}
#else
uint64_t cbm_now_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec;
}
#endif
#define CBM_USEC_PER_SEC 1000000ULL
uint64_t cbm_now_ms(void) {
return cbm_now_ns() / CBM_USEC_PER_SEC;
}
/* ── System info ───────────────────────────── */
int cbm_nprocs(void) {
#ifdef __APPLE__
int ncpu = 0;
size_t len = sizeof(ncpu);
if (sysctlbyname("hw.ncpu", &ncpu, &len, NULL, 0) == 0 && ncpu > 0) {
return ncpu;
}
enum { FILE_EXISTS = 1 };
return FILE_EXISTS;
#else
long n = sysconf(_SC_NPROCESSORS_ONLN);
return n > 0 ? (int)n : 1;
#endif
}
/* ── File system ──────────────────────────── */
bool cbm_file_exists(const char *path) {
struct stat st;
return stat(path, &st) == 0;
}
bool cbm_is_dir(const char *path) {
struct stat st;
return stat(path, &st) == 0 && S_ISDIR(st.st_mode);
}
int64_t cbm_file_size(const char *path) {
struct stat st;
if (stat(path, &st) != 0) {
return CBM_NOT_FOUND;
}
return (int64_t)st.st_size;
}
char *cbm_normalize_path_sep(char *path) {
/* Normalize on ALL platforms — backslash paths can arrive via stored
* data, cross-platform DB files, or Windows-style arguments. */
if (path) {
for (char *p = path; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
cbm_canonicalize_drive(path);
}
return path;
}
#endif /* _WIN32 */
/* ── Environment variables ──────────────────────────── */
/* Thread-safe getenv: iterates environ directly instead of calling getenv().
* getenv() is flagged by concurrency-mt-unsafe because the returned pointer
* can be invalidated by setenv/putenv in another thread. We copy to a
* caller-owned buffer immediately. */
#ifdef _WIN32
#include <stdlib.h>
#define CBM_ENVIRON _environ
#elif defined(__APPLE__)
#include <crt_externs.h>
#define CBM_ENVIRON (*_NSGetEnviron())
#else
extern char **environ;
#define CBM_ENVIRON environ
#endif
const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback) {
#ifdef _WIN32
/* #996 Layer 2: _environ holds ANSI-code-page bytes, NOT UTF-8. A
* non-ASCII value (USERPROFILE of C:\Users\Kovács János, or a Greek/CJK
* CBM_CACHE_DIR) arrives here either mojibake'd or with unrepresentable
* characters replaced by '?', which is INVALID in Windows paths — every
* downstream wide-safe file API then fails no matter how correct it is.
* Read the value wide and convert to genuine UTF-8, matching the
* UTF-8-path convention the rest of the codebase (cbm_fopen, _wmkdir,
* SQLite VFS) already assumes. */
{
wchar_t wname[CBM_SZ_256];
int wn = MultiByteToWideChar(CP_UTF8, 0, name, -1, wname, CBM_SZ_256);
if (wn > 0) {
wchar_t wval[CBM_SZ_2K];
DWORD got = GetEnvironmentVariableW(wname, wval, CBM_SZ_2K);
if (got > 0 && got < CBM_SZ_2K) {
char *utf8 = cbm_wide_to_utf8(wval);
if (utf8) {
snprintf(buf, buf_sz, "%s", utf8);
free(utf8);
return buf;
}
}
if (got == 0 && GetLastError() == ERROR_ENVVAR_NOT_FOUND) {
if (fallback) {
snprintf(buf, buf_sz, "%s", fallback);
return buf;
}
buf[0] = '\0';
return NULL;
}
}
/* Conversion trouble (oversized value, allocation) — fall through to
* the ANSI scan below rather than failing outright. */
}
#endif
char **env = CBM_ENVIRON;
if (env) {
size_t nlen = strlen(name);
for (; *env; env++) {
if (strncmp(*env, name, nlen) == 0 && (*env)[nlen] == '=') {
snprintf(buf, buf_sz, "%s", *env + nlen + SKIP_ONE);
return buf;
}
}
}
if (fallback) {
snprintf(buf, buf_sz, "%s", fallback);
return buf;
}
buf[0] = '\0';
return NULL;
}
/* ── Home directory (cross-platform) ───────────────────── */
const char *cbm_get_home_dir(void) {
static char buf[CBM_SZ_1K];
char tmp[CBM_SZ_256] = "";
cbm_safe_getenv("HOME", tmp, sizeof(tmp), NULL);
if (tmp[0]) {
snprintf(buf, sizeof(buf), "%s", tmp);
cbm_normalize_path_sep(buf);
return buf;
}
cbm_safe_getenv("USERPROFILE", tmp, sizeof(tmp), NULL);
if (tmp[0]) {
snprintf(buf, sizeof(buf), "%s", tmp);
cbm_normalize_path_sep(buf);
return buf;
}
return NULL;
}
/* ── App config directories (cross-platform) ────────── */
const char *cbm_app_config_dir(void) {
static char buf[CBM_SZ_1K];
char tmp[CBM_SZ_256] = "";
#ifdef _WIN32
cbm_safe_getenv("APPDATA", tmp, sizeof(tmp), NULL);
if (tmp[0]) {
snprintf(buf, sizeof(buf), "%s", tmp);
cbm_normalize_path_sep(buf);
return buf;
}
const char *home = cbm_get_home_dir();
if (home) {
snprintf(buf, sizeof(buf), "%s/AppData/Roaming", home);
return buf;
}
return NULL;
#else
/* Linux: XDG_CONFIG_HOME or ~/.config */
cbm_safe_getenv("XDG_CONFIG_HOME", tmp, sizeof(tmp), NULL);
if (tmp[0]) {
snprintf(buf, sizeof(buf), "%s", tmp);
return buf;
}
const char *home = cbm_get_home_dir();
if (home) {
snprintf(buf, sizeof(buf), "%s/.config", home);
return buf;
}
return NULL;
#endif /* _WIN32 */
}
const char *cbm_app_local_dir(void) {
#ifdef _WIN32
static char buf[CBM_SZ_1K];
char tmp[CBM_SZ_256] = "";
cbm_safe_getenv("LOCALAPPDATA", tmp, sizeof(tmp), NULL);
if (tmp[0]) {
snprintf(buf, sizeof(buf), "%s", tmp);
cbm_normalize_path_sep(buf);
return buf;
}
const char *home = cbm_get_home_dir();
if (home) {
snprintf(buf, sizeof(buf), "%s/AppData/Local", home);
return buf;
}
return NULL;
#else
return cbm_app_config_dir();
#endif
}
/* ── Cache directory ────────────────────────── */
const char *cbm_resolve_cache_dir(void) {
static char buf[CBM_SZ_1K];
char tmp[CBM_SZ_256] = "";
cbm_safe_getenv("CBM_CACHE_DIR", tmp, sizeof(tmp), NULL);
if (tmp[0]) {
snprintf(buf, sizeof(buf), "%s", tmp);
cbm_normalize_path_sep(buf);
return buf;
}
const char *home = cbm_get_home_dir();
if (!home) {
return NULL;
}
snprintf(buf, sizeof(buf), "%s/.cache/codebase-memory-mcp", home);
return buf;
}
+165
View File
@@ -0,0 +1,165 @@
/*
* platform.h — OS abstractions.
*
* Provides cross-platform wrappers for:
* - Memory-mapped files (mmap / VirtualAlloc)
* - High-resolution monotonic clock
* - CPU core count
* - File existence check
*/
#ifndef CBM_PLATFORM_H
#define CBM_PLATFORM_H
#include <stddef.h>
#include <stdint.h>
#include <stdbool.h>
#include <stdlib.h>
/* ── Safe memory ──────────────────────────────────────────────── */
/* Safe realloc: frees old pointer on failure instead of leaking it.
* Returns NULL on allocation failure (old memory is freed). */
static inline void *safe_realloc(void *ptr, size_t size) {
enum { SAFE_REALLOC_MIN = 1 };
if (size == 0) {
size = SAFE_REALLOC_MIN;
}
void *tmp = realloc(ptr, size);
if (!tmp) {
free(ptr);
}
return tmp;
}
/* Safe free: frees and NULLs a pointer to prevent double-free / use-after-free.
* Use via the safe_free() macro so the caller's pointer is actually cleared. */
static inline void safe_free_impl(void **pp) {
if (pp && *pp) {
free(*pp);
*pp = NULL;
}
}
#define safe_free(ptr) safe_free_impl((void **)(void *)&(ptr))
/* Safe const string free: frees a const char* and NULLs it.
* Casts away const so callers don't repeat the (void *) dance. */
static inline void safe_str_free(const char **sp) {
if (sp && *sp) {
free((void *)*sp);
*sp = NULL;
}
}
/* Safe buffer free: frees a heap array and zeros its element count.
* Use for dynamic arrays paired with a size_t count. */
static inline void safe_buf_free_impl(void **buf, size_t *count) {
if (buf && *buf) {
free(*buf);
*buf = NULL;
}
if (count) {
*count = 0;
}
}
#define safe_buf_free(buf, countp) safe_buf_free_impl((void **)(void *)&(buf), (countp))
/* Safe grow: doubles capacity and reallocs when count reaches cap.
* Note: uses safe_realloc which frees the old buffer on failure, so this is
* only appropriate for arrays whose elements don't own additional heap memory.
* For arrays of heap-allocated pointers, prefer a manual realloc+cleanup pattern.
* Usage: safe_grow(arr, count, cap, growth_factor)
* After the call, arr is the new buffer (NULL on OOM). */
#define safe_grow(arr, n, cap, factor) \
do { \
if ((size_t)(n) >= (size_t)(cap)) { \
(cap) *= (factor); \
(arr) = safe_realloc((arr), (size_t)(cap) * sizeof(*(arr))); \
} \
} while (0)
/* ── Memory mapping ────────────────────────────────────────────── */
/* Map a file read-only into memory. Returns NULL on error.
* *out_size is set to the file size. */
void *cbm_mmap_read(const char *path, size_t *out_size);
/* Unmap a previously mapped region. */
void cbm_munmap(void *addr, size_t size);
/* ── Timing ────────────────────────────────────────────────────── */
/* Monotonic nanosecond timestamp (for elapsed time measurement). */
uint64_t cbm_now_ns(void);
/* Monotonic millisecond timestamp. */
uint64_t cbm_now_ms(void);
/* ── System info ───────────────────────────────────────────────── */
/* Number of available CPU cores. */
int cbm_nprocs(void);
/* System topology: core types and RAM (only fields with production consumers). */
typedef struct {
int total_cores; /* hw.ncpu (all cores) */
int perf_cores; /* P-cores (Apple) or total_cores (others) */
size_t total_ram; /* total physical RAM in bytes */
} cbm_system_info_t;
/* Query system information. Results are cached after first call. */
cbm_system_info_t cbm_system_info(void);
/* Recommended worker count for parallel indexing.
* initial=true: all cores (user is waiting for initial index)
* initial=false: max(1, perf_cores-1) (leave headroom for user apps) */
int cbm_default_worker_count(bool initial);
/* ── Environment variables ──────────────────────────────────────── */
/* Thread-safe getenv: copies the value into a caller-provided buffer.
* Returns buf on success, or fallback if the variable is unset.
* Returns NULL when the variable is unset and fallback is NULL. */
const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback);
/* ── Home directory ─────────────────────────────────────────────── */
/* Cross-platform home directory: tries HOME first, then USERPROFILE (Windows).
* Returns NULL when neither is set. */
const char *cbm_get_home_dir(void);
/* ── App config directories ────────────────────────────────────── */
/* Cross-platform app config directory (static buffer, not thread-safe).
* Windows: %APPDATA% (e.g. C:/Users/.../AppData/Roaming)
* macOS: $HOME (callers append Library/Application Support/...)
* Linux: $XDG_CONFIG_HOME or ~/.config */
const char *cbm_app_config_dir(void);
/* Windows: %LOCALAPPDATA% (e.g. C:/Users/.../AppData/Local)
* macOS/Linux: same as cbm_app_config_dir(). */
const char *cbm_app_local_dir(void);
/* ── Cache directory ────────────────────────────────────────────── */
/* Resolve the database cache directory. All project indexes are stored here.
* Priority: CBM_CACHE_DIR env var > ~/.cache/codebase-memory-mcp (default).
* Returns static buffer or NULL if home is unavailable. */
const char *cbm_resolve_cache_dir(void);
/* ── File system ───────────────────────────────────────────────── */
/* Check if a path exists. */
bool cbm_file_exists(const char *path);
/* Check if path is a directory. */
bool cbm_is_dir(const char *path);
/* Get file size. Returns -1 on error. */
int64_t cbm_file_size(const char *path);
/* Normalize path separators to forward slashes (in-place).
* On Windows, converts backslashes to forward slashes.
* On POSIX, this is a no-op. Returns the input pointer. */
char *cbm_normalize_path_sep(char *path);
#endif /* CBM_PLATFORM_H */
+66
View File
@@ -0,0 +1,66 @@
/*
* profile.c — Activatable profiling implementation.
*/
#include "foundation/profile.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
enum {
PROF_BUF_LEN = 32,
PROF_NS_PER_US = 1000,
PROF_US_PER_MS = 1000,
PROF_US_PER_SEC = 1000000,
};
#define PROF_US_PER_SEC_D 1000000.0
bool cbm_profile_active = false;
void cbm_profile_init(void) {
const char *env = getenv("CBM_PROFILE");
if (env && env[0] != '\0' && env[0] != '0') {
cbm_profile_active = true;
}
}
void cbm_profile_enable(void) {
cbm_profile_active = true;
}
void cbm_profile_now(struct timespec *ts) {
cbm_clock_gettime(CLOCK_MONOTONIC, ts);
}
void cbm_profile_log_elapsed(const char *phase, const char *sub, const struct timespec *start,
long items) {
struct timespec now;
cbm_clock_gettime(CLOCK_MONOTONIC, &now);
long us = ((long)(now.tv_sec - start->tv_sec) * PROF_US_PER_SEC) +
((now.tv_nsec - start->tv_nsec) / PROF_NS_PER_US);
long ms = us / PROF_US_PER_MS;
char ms_buf[PROF_BUF_LEN];
char us_buf[PROF_BUF_LEN];
char items_buf[PROF_BUF_LEN];
snprintf(ms_buf, sizeof(ms_buf), "%ld", ms);
snprintf(us_buf, sizeof(us_buf), "%ld", us);
if (items > 0 && us > 0) {
long rate = (long)((double)items * PROF_US_PER_SEC_D / (double)us);
char rate_buf[PROF_BUF_LEN];
snprintf(items_buf, sizeof(items_buf), "%ld", items);
snprintf(rate_buf, sizeof(rate_buf), "%ld", rate);
cbm_log_info("prof", "phase", phase, "sub", sub, "ms", ms_buf, "us", us_buf, "items",
items_buf, "rate_per_s", rate_buf);
} else if (items > 0) {
snprintf(items_buf, sizeof(items_buf), "%ld", items);
cbm_log_info("prof", "phase", phase, "sub", sub, "ms", ms_buf, "us", us_buf, "items",
items_buf);
} else {
cbm_log_info("prof", "phase", phase, "sub", sub, "ms", ms_buf, "us", us_buf);
}
}
+58
View File
@@ -0,0 +1,58 @@
/*
* profile.h — Activatable fine-grained performance profiling.
*
* Enable via environment variable: CBM_PROFILE=1 (or any non-empty non-"0" value)
* Init is called once at program startup (from main.c).
*
* When disabled (default), the CBM_PROF_* macros cost one load + branch,
* effectively zero overhead.
*
* Output format (structured log lines, parseable):
* level=info msg=prof phase=<pass> sub=<subphase> ms=<N> us=<N> items=<N> rate_per_s=<N>
*
* Grep for `msg=prof` to get a full profile report.
*/
#ifndef CBM_PROFILE_H
#define CBM_PROFILE_H
#include <stdbool.h>
#include <time.h>
/* Runtime-active flag. Set once by cbm_profile_init() from CBM_PROFILE env. */
extern bool cbm_profile_active;
/* Initialize profiling — reads CBM_PROFILE env var. Call once at startup. */
void cbm_profile_init(void);
/* Force-enable profiling at runtime (used by CLI --profile flag). */
void cbm_profile_enable(void);
/* Get a high-resolution timestamp. */
void cbm_profile_now(struct timespec *ts);
/* Log elapsed time since `start` for the given phase/subphase.
* `items` = optional count to compute rate (pass 0 to skip). */
void cbm_profile_log_elapsed(const char *phase, const char *sub, const struct timespec *start,
long items);
/* Zero-overhead macros: a single runtime check gates everything. */
#define CBM_PROF_START(var) \
struct timespec var; \
if (cbm_profile_active) \
cbm_profile_now(&(var))
#define CBM_PROF_END(phase, sub, start_var) \
do { \
if (cbm_profile_active) { \
cbm_profile_log_elapsed((phase), (sub), &(start_var), 0); \
} \
} while (0)
#define CBM_PROF_END_N(phase, sub, start_var, items) \
do { \
if (cbm_profile_active) { \
cbm_profile_log_elapsed((phase), (sub), &(start_var), (long)(items)); \
} \
} while (0)
#endif /* CBM_PROFILE_H */
+33
View File
@@ -0,0 +1,33 @@
/*
* recursion_whitelist.h — Functions approved for recursive implementation.
*
* These functions use bounded recursion where iterative conversion would
* add complexity with no practical benefit:
*
* Cypher recursive descent parser (bounded by query nesting depth ~5):
* - parse_or_expr, parse_xor_expr, parse_and_expr, parse_not_expr
* - parse_atom_expr, parse_post_where, cbm_parse
*
* Cypher expression evaluator (bounded by WHERE clause depth ~5):
* - eval_expr
*
* Glob pattern matcher (bounded by pattern nesting ~3):
* - glob_match, glob_match_star, glob_match_doublestar
* - glob_match_doublestar_slash, glob_match_doublestar_any
*
* R import scanner (bounded by source-file AST depth):
* - r_collect_imports
*
* Extraction descendant search (bounded by AST depth):
* - find_first_descendant_by_kind (Verilog/SystemVerilog name wrappers)
* - find_first_descendant_of (Dart/Zig import URI/string nesting)
*
* To add a function: add it below AND add NOLINT(misc-no-recursion) on
* the function definition line. The lint gate verifies both match.
*/
#define CBM_RECURSION_WHITELIST \
"parse_or_expr", "parse_xor_expr", "parse_and_expr", "parse_not_expr", "parse_atom_expr", \
"parse_post_where", "cbm_parse", "eval_expr", "glob_match", "glob_match_star", \
"glob_match_doublestar", "glob_match_doublestar_slash", "glob_match_doublestar_any", \
"parse_bool_expr", "parse_bool_atom", "r_collect_imports", \
"find_first_descendant_by_kind", "find_first_descendant_of"
+129
View File
@@ -0,0 +1,129 @@
/* SHA-256 per FIPS 180-4. Straightforward reference implementation; validated
* against the NIST test vectors in tests/test_cli.c. */
#include "foundation/sha256.h"
#include <string.h>
static const uint32_t K[64] = {
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2};
#define ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
#define CH(x, y, z) (((x) & (y)) ^ (~(x) & (z)))
#define MAJ(x, y, z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define EP0(x) (ROTR(x, 2) ^ ROTR(x, 13) ^ ROTR(x, 22))
#define EP1(x) (ROTR(x, 6) ^ ROTR(x, 11) ^ ROTR(x, 25))
#define SIG0(x) (ROTR(x, 7) ^ ROTR(x, 18) ^ ((x) >> 3))
#define SIG1(x) (ROTR(x, 17) ^ ROTR(x, 19) ^ ((x) >> 10))
static void sha256_transform(cbm_sha256_ctx *c, const uint8_t *data) {
uint32_t m[64];
for (int i = 0, j = 0; i < 16; i++, j += 4) {
m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16) |
((uint32_t)data[j + 2] << 8) | (uint32_t)data[j + 3];
}
for (int i = 16; i < 64; i++) {
m[i] = SIG1(m[i - 2]) + m[i - 7] + SIG0(m[i - 15]) + m[i - 16];
}
uint32_t a = c->state[0], b = c->state[1], cc = c->state[2], d = c->state[3];
uint32_t e = c->state[4], f = c->state[5], g = c->state[6], h = c->state[7];
for (int i = 0; i < 64; i++) {
uint32_t t1 = h + EP1(e) + CH(e, f, g) + K[i] + m[i];
uint32_t t2 = EP0(a) + MAJ(a, b, cc);
h = g;
g = f;
f = e;
e = d + t1;
d = cc;
cc = b;
b = a;
a = t1 + t2;
}
c->state[0] += a;
c->state[1] += b;
c->state[2] += cc;
c->state[3] += d;
c->state[4] += e;
c->state[5] += f;
c->state[6] += g;
c->state[7] += h;
}
void cbm_sha256_init(cbm_sha256_ctx *c) {
c->bitlen = 0;
c->buflen = 0;
c->state[0] = 0x6a09e667;
c->state[1] = 0xbb67ae85;
c->state[2] = 0x3c6ef372;
c->state[3] = 0xa54ff53a;
c->state[4] = 0x510e527f;
c->state[5] = 0x9b05688c;
c->state[6] = 0x1f83d9ab;
c->state[7] = 0x5be0cd19;
}
void cbm_sha256_update(cbm_sha256_ctx *c, const void *data, size_t len) {
const uint8_t *p = (const uint8_t *)data;
for (size_t i = 0; i < len; i++) {
c->buf[c->buflen++] = p[i];
if (c->buflen == 64) {
sha256_transform(c, c->buf);
c->bitlen += 512;
c->buflen = 0;
}
}
}
void cbm_sha256_final(cbm_sha256_ctx *c, uint8_t out[CBM_SHA256_DIGEST_LEN]) {
c->bitlen += (uint64_t)c->buflen * 8;
size_t i = c->buflen;
c->buf[i++] = 0x80; /* append the '1' bit + zero padding */
if (i > 56) {
while (i < 64) {
c->buf[i++] = 0;
}
sha256_transform(c, c->buf);
i = 0;
}
while (i < 56) {
c->buf[i++] = 0;
}
/* append the 64-bit big-endian message length */
for (int j = 0; j < 8; j++) {
c->buf[56 + j] = (uint8_t)(c->bitlen >> (56 - 8 * j));
}
sha256_transform(c, c->buf);
for (int j = 0; j < 8; j++) {
out[j * 4] = (uint8_t)(c->state[j] >> 24);
out[j * 4 + 1] = (uint8_t)(c->state[j] >> 16);
out[j * 4 + 2] = (uint8_t)(c->state[j] >> 8);
out[j * 4 + 3] = (uint8_t)(c->state[j]);
}
}
void cbm_sha256_hex(const void *data, size_t len, char out[CBM_SHA256_HEX_LEN + 1]) {
uint8_t digest[CBM_SHA256_DIGEST_LEN];
cbm_sha256_ctx c;
cbm_sha256_init(&c);
cbm_sha256_update(&c, data, len);
cbm_sha256_final(&c, digest);
static const char hex[] = "0123456789abcdef";
for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) {
out[i * 2] = hex[digest[i] >> 4];
out[i * 2 + 1] = hex[digest[i] & 0x0f];
}
out[CBM_SHA256_HEX_LEN] = '\0';
}
+30
View File
@@ -0,0 +1,30 @@
#ifndef CBM_SHA256_H
#define CBM_SHA256_H
/* In-process SHA-256 (FIPS 180-4). Used to verify the integrity of a
* downloaded release before installing it, without shelling out to a
* platform hashing tool (shasum / sha256sum / certutil) — those differ per
* OS, may be absent, and mis-quote paths under cmd.exe. */
#include <stddef.h>
#include <stdint.h>
#define CBM_SHA256_DIGEST_LEN 32 /* raw digest bytes */
#define CBM_SHA256_HEX_LEN 64 /* lowercase hex chars (no NUL) */
typedef struct {
uint32_t state[8];
uint64_t bitlen;
uint8_t buf[64];
size_t buflen;
} cbm_sha256_ctx;
void cbm_sha256_init(cbm_sha256_ctx *c);
void cbm_sha256_update(cbm_sha256_ctx *c, const void *data, size_t len);
void cbm_sha256_final(cbm_sha256_ctx *c, uint8_t out[CBM_SHA256_DIGEST_LEN]);
/* One-shot hash of a buffer to lowercase hex. `out` must hold
* CBM_SHA256_HEX_LEN + 1 bytes (hex chars + NUL). */
void cbm_sha256_hex(const void *data, size_t len, char out[CBM_SHA256_HEX_LEN + 1]);
#endif /* CBM_SHA256_H */
+445
View File
@@ -0,0 +1,445 @@
/*
* slab_alloc.c — Slab allocator for tree-sitter.
*
* Replaces malloc/calloc/realloc/free for ALL tree-sitter allocations,
* eliminating ptmalloc2's per-thread arena fragmentation (the root cause
* of 321GB VSZ when indexing large codebases with 12 workers).
*
* Tier 1 (≤64B): Fixed-size slab free list.
* Matches tree-sitter SubtreeHeapData (64 bytes). O(1) alloc/free.
* Backed by 64KB slab pages (malloc = mimalloc in production).
*
* All allocations >64B go directly to malloc() which is mimalloc
* in production builds (MI_OVERRIDE=1). This eliminates the complex
* tier2 bump allocator and its O(n) ownership checks.
*
* Cross-thread frees (tree-sitter's allocator callbacks are process-global,
* so a chunk allocated on parser thread A can be freed on thread B):
*
* - Each slab page is allocated SLAB_PAGE_SIZE-aligned, so the owning page
* of ANY chunk is recovered in O(1) by masking the pointer — no per-op
* scan and no global hot-path lock. A tiny 3-level radix page map (keyed
* by page number, lock-free reads, cold-path writes) tells slab_free
* whether a masked base is a real slab page or a plain heap pointer,
* without ever dereferencing an unrelated heap address.
* - A free by the owning thread returns the chunk to the thread-local free
* list (fast path, no atomics on the list). A foreign free is pushed onto
* that page's lock-free MPSC remote-free stack; the owner drains it on its
* next refill. Neither path takes a global lock.
*
* On reclaim/destroy: free pages with no live chunks; retire pages that still
* have live chunks (owner=NULL, kept reachable) and free them when the final
* chunk returns. Each page carries a refcount = (handed-out chunks) + (1 owner
* guard while owned); the thread that drops it to zero frees the page. A page
* holding a still-live tree-sitter lexer chunk is therefore never freed under
* it (fixes the cross-thread invalid free and the #852 use-after-free).
* realloc handles slab-to-heap promotion with minimal copying.
*/
#include "foundation/constants.h"
#include "foundation/slab_alloc.h"
#include "foundation/compat.h"
#include "foundation/compat_thread.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <string.h>
/* ── Tier 1: Fixed-size slab (≤64B) ──────────────────────────────── */
/* Chunk size matches tree-sitter SubtreeHeapData (64 bytes).
* All slab allocations are this size regardless of requested size. */
#define SLAB_CHUNK_SIZE 64
/* Each slab page is a 64KB region, allocated aligned to its own size so that
* masking any interior chunk pointer with SLAB_PAGE_MASK yields the page base
* (which is where the page header lives). */
#define SLAB_PAGE_SIZE ((size_t)65536)
#define SLAB_PAGE_SHIFT 16 /* log2(SLAB_PAGE_SIZE) */
#define SLAB_PAGE_MASK (~((uintptr_t)SLAB_PAGE_SIZE - 1))
/* Chunk region starts after the page header. SLAB_DATA_OFFSET must be a
* multiple of SLAB_CHUNK_SIZE (so chunks stay 64-byte aligned) and at least
* sizeof(slab_page_t). 64 bytes leaves 1023 usable chunks per page. */
#define SLAB_DATA_OFFSET 64
#define SLAB_PAGE_CHUNKS ((SLAB_PAGE_SIZE - SLAB_DATA_OFFSET) / SLAB_CHUNK_SIZE)
/* Free list node — occupies the first 8 bytes of a free chunk. */
typedef struct slab_free_node {
struct slab_free_node *next;
} slab_free_node_t;
typedef struct slab_state slab_state_t;
/* One slab page. The header lives at the SLAB_PAGE_SIZE-aligned base; the
* SLAB_PAGE_CHUNKS chunks begin at (char*)page + SLAB_DATA_OFFSET. */
typedef struct slab_page {
struct slab_page *next; /* owner's page list (owner thread only) */
_Atomic(slab_state_t *) owner; /* owning TLS state; NULL once retired */
_Atomic(slab_free_node_t *) remote_free_head; /* lock-free MPSC stack of cross-thread frees */
atomic_uint refcount; /* handed-out chunks + 1 owner guard (while owned) */
} slab_page_t;
_Static_assert(sizeof(slab_page_t) <= SLAB_DATA_OFFSET,
"slab_page header must fit in SLAB_DATA_OFFSET");
_Static_assert(SLAB_DATA_OFFSET % SLAB_CHUNK_SIZE == 0, "SLAB_DATA_OFFSET must be chunk-aligned");
/* Per-thread Tier 1 state. */
struct slab_state {
slab_page_t *pages; /* linked list of pages owned by this thread */
slab_free_node_t *freelist; /* singly-linked free list (thread-local) */
bool installed;
};
static CBM_TLS slab_state_t tls_slab;
/* ── Page map: safe O(1) "is this a slab page?" lookup ───────────────
*
* A 3-level radix trie keyed by page number (base >> SLAB_PAGE_SHIFT) covering
* the low 32 bits of the page number — i.e. all 48-bit addresses that malloc/
* posix_memalign/_aligned_malloc return on every supported platform. Reads are
* lock-free (atomic-acquire loads only, never dereferencing the queried
* pointer). Writes (page create / free / retire — all cold paths) take a
* dedicated spinlock that is NEVER touched on the alloc/free hot path.
*
* Intermediate tables are never freed during the run and stay reachable from
* the static root, so lock-free readers never touch freed table memory. */
#define SLAB_MAP_L1_BITS 11
#define SLAB_MAP_L2_BITS 11
#define SLAB_MAP_L3_BITS 10
#define SLAB_MAP_TOTAL_BITS (SLAB_MAP_L1_BITS + SLAB_MAP_L2_BITS + SLAB_MAP_L3_BITS) /* 32 */
#define SLAB_MAP_L1_SIZE ((size_t)1 << SLAB_MAP_L1_BITS)
#define SLAB_MAP_L2_SIZE ((size_t)1 << SLAB_MAP_L2_BITS)
#define SLAB_MAP_L3_SIZE ((size_t)1 << SLAB_MAP_L3_BITS)
typedef struct {
_Atomic(slab_page_t *) e[SLAB_MAP_L3_SIZE];
} slab_map_l3_t;
typedef struct {
_Atomic(slab_map_l3_t *) e[SLAB_MAP_L2_SIZE];
} slab_map_l2_t;
static _Atomic(slab_map_l2_t *) g_slab_map_root[SLAB_MAP_L1_SIZE];
static atomic_flag g_slab_map_lock = ATOMIC_FLAG_INIT;
static void slab_map_lock(void) {
while (atomic_flag_test_and_set_explicit(&g_slab_map_lock, memory_order_acquire)) {
/* Spin — cold path only (page create/destroy/retire). */
}
}
static void slab_map_unlock(void) {
atomic_flag_clear_explicit(&g_slab_map_lock, memory_order_release);
}
static bool slab_map_indices(uintptr_t base, size_t *i1, size_t *i2, size_t *i3) {
uintptr_t pnum = base >> SLAB_PAGE_SHIFT;
if ((pnum >> SLAB_MAP_TOTAL_BITS) != 0) {
return false; /* address beyond coverage — never produced by malloc here */
}
*i1 = (size_t)((pnum >> (SLAB_MAP_L2_BITS + SLAB_MAP_L3_BITS)) & (SLAB_MAP_L1_SIZE - 1));
*i2 = (size_t)((pnum >> SLAB_MAP_L3_BITS) & (SLAB_MAP_L2_SIZE - 1));
*i3 = (size_t)(pnum & (SLAB_MAP_L3_SIZE - 1));
return true;
}
/* Lock-free. Returns the slab page whose base == (ptr & mask), or NULL if the
* masked base is not a registered slab page (i.e. ptr is a plain heap block). */
static slab_page_t *slab_map_lookup(uintptr_t base) {
size_t i1, i2, i3;
if (!slab_map_indices(base, &i1, &i2, &i3)) {
return NULL;
}
slab_map_l2_t *l2 = atomic_load_explicit(&g_slab_map_root[i1], memory_order_acquire);
if (!l2) {
return NULL;
}
slab_map_l3_t *l3 = atomic_load_explicit(&l2->e[i2], memory_order_acquire);
if (!l3) {
return NULL;
}
return atomic_load_explicit(&l3->e[i3], memory_order_acquire);
}
/* Cold path. Sets the leaf entry for `base` to `val` (page to register, NULL to
* unregister), allocating intermediate tables on demand. Returns false only if
* a needed table allocation fails while registering. */
static bool slab_map_set(uintptr_t base, slab_page_t *val) {
size_t i1, i2, i3;
if (!slab_map_indices(base, &i1, &i2, &i3)) {
return false;
}
slab_map_lock();
slab_map_l2_t *l2 = atomic_load_explicit(&g_slab_map_root[i1], memory_order_relaxed);
if (!l2) {
if (!val) {
slab_map_unlock();
return true; /* nothing to unregister */
}
l2 = (slab_map_l2_t *)calloc(1, sizeof(*l2));
if (!l2) {
slab_map_unlock();
return false;
}
atomic_store_explicit(&g_slab_map_root[i1], l2, memory_order_release);
}
slab_map_l3_t *l3 = atomic_load_explicit(&l2->e[i2], memory_order_relaxed);
if (!l3) {
if (!val) {
slab_map_unlock();
return true;
}
l3 = (slab_map_l3_t *)calloc(1, sizeof(*l3));
if (!l3) {
slab_map_unlock();
return false;
}
atomic_store_explicit(&l2->e[i2], l3, memory_order_release);
}
atomic_store_explicit(&l3->e[i3], val, memory_order_release);
slab_map_unlock();
return true;
}
/* ── Tier 1 helpers ────────────────────────────────────────────────── */
static void slab_free(void *ptr);
static bool slab_map_register_page(slab_page_t *page);
static void slab_map_unregister_page(slab_page_t *page);
/* Allocate and register a new SLAB_PAGE_SIZE-aligned page, threading its chunks
* onto the thread-local free list. Returns false on OOM (caller falls back to
* malloc). Registration happens before any chunk is handed out so that a later
* cross-thread free can always recover the page. */
static bool slab_grow(slab_state_t *s) {
void *mem = NULL;
if (cbm_aligned_alloc(&mem, SLAB_PAGE_SIZE, SLAB_PAGE_SIZE) != 0 || !mem) {
return false;
}
slab_page_t *page = (slab_page_t *)mem;
page->next = s->pages;
atomic_init(&page->owner, s);
atomic_init(&page->remote_free_head, (slab_free_node_t *)NULL);
atomic_init(&page->refcount, 1u); /* owner guard */
if (!slab_map_register_page(page)) {
cbm_aligned_free(page);
return false;
}
s->pages = page;
for (size_t i = 0; i < SLAB_PAGE_CHUNKS; i++) {
slab_free_node_t *node =
(slab_free_node_t *)((char *)page + SLAB_DATA_OFFSET + i * SLAB_CHUNK_SIZE);
node->next = s->freelist;
s->freelist = node;
}
return true;
}
/* Refill the thread-local free list: first reclaim any cross-thread frees
* queued on owned pages, then grow a fresh page if still empty. */
static void slab_refill(slab_state_t *s) {
for (slab_page_t *p = s->pages; p; p = p->next) {
slab_free_node_t *rf = atomic_exchange_explicit(
&p->remote_free_head, (slab_free_node_t *)NULL, memory_order_acquire);
while (rf) {
slab_free_node_t *next = rf->next;
rf->next = s->freelist;
s->freelist = rf;
rf = next;
}
}
if (s->freelist) {
return;
}
(void)slab_grow(s);
}
/* Retire ownership and drop the owner guard for every page this thread owns.
* A page with no live chunks is freed immediately; a page still holding
* foreign-live chunks is retired (owner=NULL, left registered) and freed by
* whichever thread returns its last chunk. */
static void slab_reclaim_pages(slab_state_t *s, bool clear_installed) {
slab_page_t *p = s->pages;
while (p) {
slab_page_t *next = p->next;
atomic_store_explicit(&p->owner, (slab_state_t *)NULL, memory_order_relaxed);
unsigned prev = atomic_fetch_sub_explicit(&p->refcount, 1u, memory_order_acq_rel);
if (prev == 1u) {
slab_map_unregister_page(p);
cbm_aligned_free(p);
}
p = next;
}
s->pages = NULL;
s->freelist = NULL;
if (clear_installed) {
s->installed = false;
}
}
/* ── Allocator functions (installed as tree-sitter callbacks) ───── */
static void *slab_malloc(size_t size) {
if (size == 0) {
size = SKIP_ONE;
}
/* Tier 1: ≤64B → slab free list */
if (size <= SLAB_CHUNK_SIZE) {
slab_state_t *s = &tls_slab;
if (!s->freelist) {
slab_refill(s);
if (!s->freelist) {
return malloc(size); /* grow failed → heap fallback */
}
}
slab_free_node_t *node = s->freelist;
s->freelist = node->next;
slab_page_t *page = (slab_page_t *)((uintptr_t)node & SLAB_PAGE_MASK);
atomic_fetch_add_explicit(&page->refcount, 1u, memory_order_relaxed);
return node;
}
/* >64B: straight to malloc (= mimalloc in production) */
return malloc(size);
}
static void *slab_calloc(size_t count, size_t size) {
/* Overflow check */
if (count > 0 && size > SIZE_MAX / count) {
return NULL;
}
size_t total = count * size;
void *ptr = slab_malloc(total);
if (ptr) {
/* Must zero: free-list recycled blocks contain stale data. */
memset(ptr, 0, total);
}
return ptr;
}
static void *slab_realloc(void *ptr, size_t new_size) {
if (!ptr) {
return slab_malloc(new_size);
}
if (new_size == 0) {
/* realloc(ptr, 0) = free + return NULL */
slab_free(ptr);
return NULL;
}
/* Case 1: ptr is in a slab page (≤64B block) */
slab_page_t *page = slab_map_lookup((uintptr_t)ptr & SLAB_PAGE_MASK);
if (page) {
if (new_size <= SLAB_CHUNK_SIZE) {
/* Still fits in a slab chunk — reuse same slot */
return ptr;
}
/* Promote slab → heap */
void *new_ptr = malloc(new_size);
if (!new_ptr) {
return NULL;
}
memcpy(new_ptr, ptr, SLAB_CHUNK_SIZE);
slab_free(ptr);
return new_ptr;
}
/* Case 2: heap pointer (from malloc) */
return realloc(ptr, new_size);
}
static void slab_free(void *ptr) {
if (!ptr) {
return;
}
uintptr_t base = (uintptr_t)ptr & SLAB_PAGE_MASK;
slab_page_t *page = slab_map_lookup(base);
if (!page) {
/* Not a slab chunk → plain heap pointer (>64B or grow-fallback). */
free(ptr);
return;
}
/* It is a slab chunk. The chunk itself keeps its page alive (refcount ≥ 1)
* until this very free, so the page cannot be freed underneath us. */
slab_state_t *s = &tls_slab;
slab_free_node_t *node = (slab_free_node_t *)ptr;
if (atomic_load_explicit(&page->owner, memory_order_relaxed) == s) {
/* Fast path: owner returns the chunk to its thread-local free list. */
node->next = s->freelist;
s->freelist = node;
atomic_fetch_sub_explicit(&page->refcount, 1u, memory_order_acq_rel);
return; /* owner guard keeps refcount ≥ 1; page is reused, not freed */
}
/* Foreign (or retired) free: push onto the page's lock-free MPSC stack. */
slab_free_node_t *head = atomic_load_explicit(&page->remote_free_head, memory_order_relaxed);
do {
node->next = head;
} while (!atomic_compare_exchange_weak_explicit(&page->remote_free_head, &head, node,
memory_order_release, memory_order_relaxed));
unsigned prev = atomic_fetch_sub_explicit(&page->refcount, 1u, memory_order_acq_rel);
if (prev == 1u) {
/* We returned the final chunk of a retired page → release it. */
slab_map_unregister_page(page);
cbm_aligned_free(page);
}
}
/* ── Page-map registration wrappers (declared after slab_map_set) ── */
static bool slab_map_register_page(slab_page_t *page) {
return slab_map_set((uintptr_t)page, page);
}
static void slab_map_unregister_page(slab_page_t *page) {
(void)slab_map_set((uintptr_t)page, (slab_page_t *)NULL);
}
/* ── Public API ─────────────────────────────────────────────────── */
/* Forward declaration of tree-sitter's allocator setter. */
extern void ts_set_allocator(void *(*new_malloc)(size_t), void *(*new_calloc)(size_t, size_t),
void *(*new_realloc)(void *, size_t), void (*new_free)(void *));
void cbm_slab_install(void) {
ts_set_allocator(slab_malloc, slab_calloc, slab_realloc, slab_free);
}
void cbm_slab_reset_thread(void) {
cbm_slab_reclaim();
}
void cbm_slab_destroy_thread(void) {
slab_reclaim_pages(&tls_slab, true);
}
/* Reclaim slab memory owned by the current thread.
*
* Call ONLY when no LOCAL live allocations remain — i.e., after ts_tree_delete()
* AND ts_parser_delete() have freed this thread's parser-owned chunks. If a
* chunk from one of these pages is still held by another parser thread, the
* page is retired instead of freed and released on the final cross-thread free.
* This keeps peak memory bounded per-file without handing foreign slab chunks
* to plain free(). */
void cbm_slab_reclaim(void) {
slab_reclaim_pages(&tls_slab, false);
}
/* ── Test API (thin wrappers for unit testing) ──────────────────── */
void *cbm_slab_test_malloc(size_t size) {
return slab_malloc(size);
}
void cbm_slab_test_free(void *ptr) {
slab_free(ptr);
}
void *cbm_slab_test_realloc(void *ptr, size_t size) {
return slab_realloc(ptr, size);
}
void *cbm_slab_test_calloc(size_t count, size_t size) {
return slab_calloc(count, size);
}
+60
View File
@@ -0,0 +1,60 @@
/*
* slab_alloc.h — Slab allocator for tree-sitter.
*
* Replaces malloc/calloc/realloc/free for ALL tree-sitter allocations
* to eliminate ptmalloc2's per-thread arena fragmentation.
*
* Tier 1 (≤64B): Fixed-size slab free list — O(1) alloc/free.
* Matches tree-sitter SubtreeHeapData (CBM_SZ_64 bytes). Backed by
* 64KB slab pages, each allocated aligned to its own size so the owning
* page of any chunk is recovered in O(1) by masking the pointer. Pages are
* reused per thread; cross-thread frees are recognized as slab pointers
* (never handed to plain free()) and a page holding a still-live chunk is
* retired, then freed when the final chunk returns. No global lock and no
* per-op scan on the alloc/free hot path.
*
* All allocations >64B go directly to malloc (= mimalloc in production),
* which handles size classes, thread caching, and OS page return
* far better than a hand-rolled tier2 bump allocator.
*
* Usage:
* cbm_slab_install(); // once, before any parsing
* ... parse files ...
* cbm_slab_destroy_thread(); // on thread exit — frees owned memory
*/
#ifndef CBM_SLAB_ALLOC_H
#define CBM_SLAB_ALLOC_H
#include <stddef.h>
/* Install slab allocator as tree-sitter's malloc/calloc/realloc/free.
* Must be called once before any ts_parser_new() calls. Thread-safe. */
void cbm_slab_install(void);
/* Reset the current thread's slab: owned pages are reclaimed or retired.
* WARNING: Do NOT call between files if the parser retains live state.
* Only safe after cbm_destroy_thread_parser() has been called. */
void cbm_slab_reset_thread(void);
/* Destroy the current thread's allocator state. Pages with no live chunks are
* freed; pages still holding cross-thread live chunks are retired and freed on
* the last free. Call on thread exit. */
void cbm_slab_destroy_thread(void);
/* Reclaim current-thread slab memory.
* Call ONLY when no LOCAL live allocations remain (after ts_tree_delete AND
* ts_parser_delete). If another parser thread still holds a chunk from one of
* these pages, that page is retired instead of freed and released on the final
* cross-thread free. Keeps the allocator installed — next allocation will grow
* fresh pages as needed. This bounds peak memory per-file rather than
* accumulating across all files in a worker. */
void cbm_slab_reclaim(void);
/* Test/diagnostic API: direct access to the slab allocator.
* Use these to unit test slab (≤64B) and heap (>64B) paths. */
void *cbm_slab_test_malloc(size_t size);
void cbm_slab_test_free(void *ptr);
void *cbm_slab_test_realloc(void *ptr, size_t size);
void *cbm_slab_test_calloc(size_t count, size_t size);
#endif /* CBM_SLAB_ALLOC_H */
+153
View File
@@ -0,0 +1,153 @@
/*
* str_intern.c — String interning pool.
*
* Uses a hash table for O(1) dedup lookup and an arena for string storage.
* All strings are copied into the arena — the pool owns the memory.
*/
#include "str_intern.h"
#include "arena.h"
#include "foundation/constants.h"
enum { INTERN_LOAD_NUM = 7 };
#include <stdint.h> // uint32_t
#include <stdlib.h>
#include <string.h>
/* FNV-1a hash constants (published by Fowler/Noll/Vo) */
#define FNV_OFFSET_BASIS 2166136261U
#define FNV_PRIME 16777619U
/* FNV-1a for interning (matches hash_table.c) */
static uint32_t intern_hash(const char *s, size_t len) {
uint32_t h = FNV_OFFSET_BASIS;
for (size_t i = 0; i < len; i++) {
h ^= (unsigned char)s[i];
h *= FNV_PRIME;
}
return h;
}
/* Bucket for open-addressing */
typedef struct {
const char *str; /* arena-owned pointer */
uint32_t hash;
uint32_t len; /* string length (excluding NUL) */
} InternEntry;
struct CBMInternPool {
CBMArena arena;
InternEntry *buckets;
uint32_t capacity;
uint32_t count;
uint32_t mask;
size_t total_bytes; /* sum of string lengths stored */
};
CBMInternPool *cbm_intern_create(void) {
CBMInternPool *p = (CBMInternPool *)calloc(CBM_ALLOC_ONE, sizeof(*p));
if (!p) {
return NULL;
}
cbm_arena_init(&p->arena);
p->capacity = CBM_SZ_256;
p->mask = p->capacity - SKIP_ONE;
p->buckets = (InternEntry *)calloc(p->capacity, sizeof(InternEntry));
if (!p->buckets) {
cbm_arena_destroy(&p->arena);
free(p);
return NULL;
}
return p;
}
void cbm_intern_free(CBMInternPool *pool) {
if (!pool) {
return;
}
cbm_arena_destroy(&pool->arena);
free(pool->buckets);
free(pool);
}
static void intern_resize(CBMInternPool *p) {
uint32_t new_cap = p->capacity * PAIR_LEN;
uint32_t new_mask = new_cap - SKIP_ONE;
InternEntry *new_buckets = (InternEntry *)calloc(new_cap, sizeof(InternEntry));
if (!new_buckets) {
return;
}
for (uint32_t i = 0; i < p->capacity; i++) {
const InternEntry *e = &p->buckets[i];
if (!e->str) {
continue;
}
uint32_t idx = e->hash & new_mask;
while (new_buckets[idx].str) {
idx = (idx + SKIP_ONE) & new_mask;
}
new_buckets[idx] = *e;
}
free(p->buckets);
p->buckets = new_buckets;
p->capacity = new_cap;
p->mask = new_mask;
}
const char *cbm_intern_n(CBMInternPool *pool, const char *s, size_t len) {
if (!pool || !s) {
return NULL;
}
uint32_t h = intern_hash(s, len);
uint32_t idx = h & pool->mask;
/* Probe for existing entry */
for (;;) {
const InternEntry *e = &pool->buckets[idx];
if (!e->str) {
break; /* empty slot — not found */
}
if (e->hash == h && e->len == (uint32_t)len && memcmp(e->str, s, len) == 0) {
return e->str; /* found — return existing pointer */
}
idx = (idx + SKIP_ONE) & pool->mask;
}
/* Resize at 70% load */
if (pool->count * CBM_DECIMAL_BASE >= pool->capacity * INTERN_LOAD_NUM) {
intern_resize(pool);
/* Re-probe after resize */
idx = h & pool->mask;
while (pool->buckets[idx].str) {
idx = (idx + SKIP_ONE) & pool->mask;
}
}
/* Copy string into arena */
char *copy = cbm_arena_strndup(&pool->arena, s, len);
if (!copy) {
return NULL;
}
pool->buckets[idx] = (InternEntry){.str = copy, .hash = h, .len = (uint32_t)len};
pool->count++;
pool->total_bytes += len;
return copy;
}
const char *cbm_intern(CBMInternPool *pool, const char *s) {
if (!s) {
return NULL;
}
return cbm_intern_n(pool, s, strlen(s));
}
uint32_t cbm_intern_count(const CBMInternPool *pool) {
return pool ? pool->count : 0;
}
size_t cbm_intern_bytes(const CBMInternPool *pool) {
return pool ? pool->total_bytes : 0;
}
+36
View File
@@ -0,0 +1,36 @@
/*
* str_intern.h — String interning pool.
*
* Deduplicates strings: identical strings share a single allocation.
* Returns stable pointers — safe to compare by pointer equality after interning.
*
* Uses an arena for string storage (bulk free) + hash table for dedup lookup.
*/
#ifndef CBM_STR_INTERN_H
#define CBM_STR_INTERN_H
#include <stddef.h>
#include <stdint.h>
typedef struct CBMInternPool CBMInternPool;
/* Create a new intern pool. */
CBMInternPool *cbm_intern_create(void);
/* Free the pool and all interned strings. */
void cbm_intern_free(CBMInternPool *pool);
/* Intern a NUL-terminated string. Returns a stable pointer.
* The same input always returns the same pointer. */
const char *cbm_intern(CBMInternPool *pool, const char *s);
/* Intern a string of known length. */
const char *cbm_intern_n(CBMInternPool *pool, const char *s, size_t len);
/* Number of unique strings in the pool. */
uint32_t cbm_intern_count(const CBMInternPool *pool);
/* Total bytes stored (unique strings only). */
size_t cbm_intern_bytes(const CBMInternPool *pool);
#endif /* CBM_STR_INTERN_H */
+343
View File
@@ -0,0 +1,343 @@
/*
* str_util.c — Safe string operations (arena-allocated).
*/
#include "str_util.h"
#include "arena.h" // CBMArena, cbm_arena_alloc/strdup/strndup
#include "foundation/constants.h"
#include <string.h>
#include <ctype.h>
#include <stdio.h>
enum {
JSON_ESC_LEN = 2, /* escaped char takes 2 bytes (backslash + char) */
JSON_NUL_RESERVE = 1, /* reserve 1 byte for NUL terminator */
JSON_CTRL_LIMIT = 0x20, /* ASCII control character upper bound */
};
char *cbm_path_join(CBMArena *a, const char *base, const char *name) {
if (!base || !name) {
return NULL;
}
size_t blen = strlen(base);
size_t nlen = strlen(name);
/* Handle empty components */
if (blen == 0) {
return cbm_arena_strdup(a, name);
}
if (nlen == 0) {
return cbm_arena_strdup(a, base);
}
/* Strip trailing slash from base */
while (blen > 0 && base[blen - SKIP_ONE] == '/') {
blen--;
}
/* Strip leading slash from name */
while (nlen > 0 && *name == '/') {
name++;
nlen--;
}
if (blen == 0) {
return cbm_arena_strndup(a, name, nlen);
}
if (nlen == 0) {
return cbm_arena_strndup(a, base, blen);
}
char *result = (char *)cbm_arena_alloc(a, blen + SKIP_ONE + nlen + SKIP_ONE);
if (!result) {
return NULL;
}
memcpy(result, base, blen);
result[blen] = '/';
memcpy(result + blen + SKIP_ONE, name, nlen);
result[blen + SKIP_ONE + nlen] = '\0';
return result;
}
char *cbm_path_join_n(CBMArena *a, const char **parts, int n) {
if (n <= 0 || !parts) {
return cbm_arena_strdup(a, "");
}
if (n == SKIP_ONE) {
return cbm_arena_strdup(a, parts[0]);
}
char *result = cbm_arena_strdup(a, parts[0]);
for (int i = SKIP_ONE; i < n; i++) {
result = cbm_path_join(a, result, parts[i]);
}
return result;
}
const char *cbm_path_ext(const char *path) {
if (!path) {
return "";
}
const char *dot = NULL;
const char *slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '.') {
dot = p;
}
if (*p == '/') {
slash = p;
}
}
/* dot must be after last slash and not at start of basename */
if (!dot) {
return "";
}
if (slash && dot < slash) {
return "";
}
return dot + SKIP_ONE;
}
const char *cbm_path_base(const char *path) {
if (!path) {
return "";
}
const char *last_slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '/') {
last_slash = p;
}
}
return last_slash ? last_slash + SKIP_ONE : path;
}
char *cbm_path_dir(CBMArena *a, const char *path) {
if (!path) {
return cbm_arena_strdup(a, ".");
}
const char *last_slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '/') {
last_slash = p;
}
}
if (!last_slash) {
return cbm_arena_strdup(a, ".");
}
return cbm_arena_strndup(a, path, (size_t)(last_slash - path));
}
bool cbm_str_starts_with(const char *s, const char *prefix) {
if (!s || !prefix) {
return false;
}
size_t plen = strlen(prefix);
return strncmp(s, prefix, plen) == 0;
}
bool cbm_str_ends_with(const char *s, const char *suffix) {
if (!s || !suffix) {
return false;
}
size_t slen = strlen(s);
size_t xlen = strlen(suffix);
if (xlen > slen) {
return false;
}
return strcmp(s + slen - xlen, suffix) == 0;
}
bool cbm_str_contains(const char *s, const char *sub) {
if (!s || !sub) {
return false;
}
if (sub[0] == '\0') {
return true;
}
return strstr(s, sub) != NULL;
}
char *cbm_str_tolower(CBMArena *a, const char *s) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *result = (char *)cbm_arena_alloc(a, len + SKIP_ONE);
if (!result) {
return NULL;
}
for (size_t i = 0; i < len; i++) {
result[i] = (char)tolower((unsigned char)s[i]);
}
result[len] = '\0';
return result;
}
char *cbm_str_replace_char(CBMArena *a, const char *s, char from, char to) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *result = (char *)cbm_arena_alloc(a, len + SKIP_ONE);
if (!result) {
return NULL;
}
for (size_t i = 0; i < len; i++) {
result[i] = (s[i] == from) ? to : s[i];
}
result[len] = '\0';
return result;
}
char *cbm_str_strip_ext(CBMArena *a, const char *path) {
if (!path) {
return NULL;
}
const char *dot = NULL;
const char *slash = NULL;
for (const char *p = path; *p; p++) {
if (*p == '.') {
dot = p;
}
if (*p == '/') {
slash = p;
}
}
if (!dot || (slash && dot < slash)) {
return cbm_arena_strdup(a, path);
}
return cbm_arena_strndup(a, path, (size_t)(dot - path));
}
char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count) {
if (!s || !out_count) {
return NULL;
}
/* Count parts */
int count = SKIP_ONE;
for (const char *p = s; *p; p++) {
if (*p == delim) {
count++;
}
}
char **result = (char **)cbm_arena_alloc(a, (size_t)(count + SKIP_ONE) * sizeof(char *));
if (!result) {
return NULL;
}
int idx = 0;
const char *start = s;
for (const char *p = s;; p++) {
if (*p == delim || *p == '\0') {
size_t part_len = (size_t)(p - start);
result[idx++] = cbm_arena_strndup(a, start, part_len);
if (*p == '\0') {
break;
}
start = p + SKIP_ONE;
}
}
result[idx] = NULL;
*out_count = count;
return result;
}
bool cbm_validate_shell_arg(const char *s) {
if (!s) {
return false;
}
for (const char *p = s; *p; p++) {
switch (*p) {
case '\'':
case '"':
case ';':
case '|':
case '&':
case '$':
case '`':
case '<':
case '>':
case '\n':
case '\r':
#ifndef _WIN32
case '\\':
#endif
return false;
default:
break;
}
}
return true;
}
bool cbm_validate_project_name(const char *name) {
if (!name || !*name)
return false;
/* Reject directory traversal */
if (strcmp(name, "..") == 0 || strstr(name, "..") != NULL)
return false;
/* Reject path separators */
if (strchr(name, '/') || strchr(name, '\\'))
return false;
/* Reject leading dot (hidden files / relative refs) */
if (name[0] == '.')
return false;
/* Allow only alphanumeric, dash, underscore, dot */
for (const char *p = name; *p; p++) {
if (!(((*p >= 'a') && (*p <= 'z')) || ((*p >= 'A') && (*p <= 'Z')) ||
((*p >= '0') && (*p <= '9')) || *p == '-' || *p == '_' || *p == '.')) {
return false;
}
}
return true;
}
int cbm_json_escape(char *buf, int bufsize, const char *src) {
if (!buf || bufsize <= 0) {
return 0;
}
if (!src) {
buf[0] = '\0';
return 0;
}
int pos = 0;
for (int i = 0; src[i] && pos < bufsize - JSON_NUL_RESERVE; i++) {
unsigned char c = (unsigned char)src[i];
if (c == '"' || c == '\\') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = (char)c;
} else if (c == '\n') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = 'n';
} else if (c == '\r') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = 'r';
} else if (c == '\t') {
if (pos + JSON_ESC_LEN > bufsize - JSON_NUL_RESERVE) {
break;
}
buf[pos++] = '\\';
buf[pos++] = 't';
} else if (c < JSON_CTRL_LIMIT) {
/* Other control chars: escape as \u00XX */
if (pos + 6 > bufsize - JSON_NUL_RESERVE) {
break;
}
pos += snprintf(buf + pos, 7, "\\u%04x", c);
} else {
buf[pos++] = (char)c;
}
}
buf[pos] = '\0';
return pos;
}
+89
View File
@@ -0,0 +1,89 @@
/*
* str_util.h — Safe string operations.
*
* All functions that return char* allocate via the provided arena
* (no malloc, no free needed).
*/
#ifndef CBM_STR_UTIL_H
#define CBM_STR_UTIL_H
#include "arena.h"
#include <stdbool.h>
#include <stddef.h>
/* Join two path components with '/'. Handles trailing/leading slashes. */
char *cbm_path_join(CBMArena *a, const char *base, const char *name);
/* Join N path components. parts is an array of N strings. */
char *cbm_path_join_n(CBMArena *a, const char **parts, int n);
/* Get the file extension (without dot). Returns "" if none. */
const char *cbm_path_ext(const char *path);
/* Get the base name (after last '/'). Returns path if no '/'. */
const char *cbm_path_base(const char *path);
/* Get the directory part (before last '/'). Returns "." if no '/'. */
char *cbm_path_dir(CBMArena *a, const char *path);
/* Check if string starts with prefix. */
bool cbm_str_starts_with(const char *s, const char *prefix);
/* Check if string ends with suffix. */
bool cbm_str_ends_with(const char *s, const char *suffix);
/* Check if string contains substring. */
bool cbm_str_contains(const char *s, const char *sub);
/* Convert to lowercase (arena-allocated copy). */
char *cbm_str_tolower(CBMArena *a, const char *s);
/* Replace all occurrences of 'from' char with 'to' char (arena copy). */
char *cbm_str_replace_char(CBMArena *a, const char *s, char from, char to);
/* Strip file extension: "foo.go" → "foo" (arena copy). */
char *cbm_str_strip_ext(CBMArena *a, const char *path);
/* Split string by delimiter. Returns arena-allocated array + count.
* The array itself and all substrings are arena-allocated. */
char **cbm_str_split(CBMArena *a, const char *s, char delim, int *out_count);
/* Validate a string is safe for shell interpolation inside single quotes.
* Rejects: ' " ; | & $ ` < > \n \r \0 (embedded NULs via len check).
* The Windows search path wraps shell args in cmd.exe-level "powershell -Command
* \"...'%s'...\"", so " can close the cmd.exe outer quote even if PowerShell's
* single quotes hold; < > would then become cmd.exe redirection (file-write
* primitive). Blocking these unconditionally hardens both POSIX and Windows.
* Returns true if safe, false if the string contains shell metacharacters. */
bool cbm_validate_shell_arg(const char *s);
/* Validate a project name is safe for file path construction.
* Allows: alphanumeric, dash, underscore, dot (but not leading dot or dot-dot).
* Rejects: path separators (/ \), directory traversal (..), and control chars.
* Returns true if safe, false if the name could escape the cache directory. */
bool cbm_validate_project_name(const char *name);
/* Safe snprintf append: clamps offset to prevent buffer overflow on truncation.
* When snprintf truncates, it returns what it WOULD have written, which can make
* offset > bufsize. Next call: bufsize - offset wraps unsigned → huge → overflow.
* This macro guards against that by checking bounds before writing and clamping after.
*
* Usage: CBM_SNPRINTF_APPEND(buf, sizeof(buf), off, "fmt %s", arg);
* Requires: <stdio.h> included by caller. */
#define CBM_SNPRINTF_APPEND(buf, sz, off, ...) \
do { \
if ((off) >= 0 && (off) < (int)(sz)) { \
int _cbm_r = snprintf((buf) + (off), (sz) - (size_t)(off), __VA_ARGS__); \
if (_cbm_r > 0) \
(off) += _cbm_r; \
if ((off) >= (int)(sz)) \
(off) = (int)(sz) - 1; \
} \
} while (0)
/* Escape a string for safe embedding in JSON: escapes " \ and control chars.
* Writes into buf (including NUL). Returns number of chars written (excl NUL).
* If buf is too small, output is truncated but always NUL-terminated. */
int cbm_json_escape(char *buf, int bufsize, const char *src);
#endif /* CBM_STR_UTIL_H */
+413
View File
@@ -0,0 +1,413 @@
/*
* subprocess.c — cross-platform spawn + supervise + classify.
* See subprocess.h. The spawn/reap skeleton mirrors src/ui/http_server.c's
* index subprocess; this generalizes it and adds crash/hang classification.
*/
#include "subprocess.h"
#include "compat.h" /* cbm_nanosleep */
#include "platform.h" /* cbm_now_ms */
#include <stdio.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#include "win_utf8.h" /* cbm_utf8_to_wide — spawn the worker with a wide command line so a
* non-ASCII repo path survives CreateProcess (#423/#20) */
#include <stdlib.h> /* free */
#else
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
/* NTSTATUS severity ERROR (top two bits set) covers the Windows crash exception
* exit codes: 0xC0000005 (access violation), 0xC00000FD (stack overflow),
* 0xC000001D (illegal instruction), 0xC0000094 (integer divide by zero), … */
#define CBM_WIN_CRASH_CODE_MIN 0xC0000000u
#ifndef _WIN32
static bool cbm_is_fault_signal(int sig) {
switch (sig) {
case SIGSEGV:
case SIGBUS:
case SIGILL:
case SIGFPE:
case SIGABRT:
case SIGSYS:
return true;
default:
return false;
}
}
#endif
cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int term_signal,
bool timed_out) {
if (timed_out) {
return CBM_PROC_HANG;
}
if (!exited_normally) {
/* POSIX signal death. */
#ifndef _WIN32
if (cbm_is_fault_signal(term_signal)) {
return CBM_PROC_CRASH;
}
#else
(void)term_signal;
#endif
return CBM_PROC_KILLED;
}
/* Exited with a code. A Windows NTSTATUS exception code is a crash; on POSIX
* exit codes are 0..255 so this branch never misfires there. */
if ((unsigned)exit_code >= CBM_WIN_CRASH_CODE_MIN) {
return CBM_PROC_CRASH;
}
return (exit_code == 0) ? CBM_PROC_CLEAN : CBM_PROC_EXIT_NONZERO;
}
const char *cbm_proc_outcome_str(cbm_proc_outcome_t o) {
switch (o) {
case CBM_PROC_CLEAN:
return "clean";
case CBM_PROC_EXIT_NONZERO:
return "exit_nonzero";
case CBM_PROC_CRASH:
return "crash";
case CBM_PROC_HANG:
return "hang";
case CBM_PROC_KILLED:
return "killed";
case CBM_PROC_SPAWN_FAILED:
default:
return "spawn_failed";
}
}
/* Tail newly-appended complete lines from the child log, starting at *tail_pos.
* A partial (non-newline-terminated) final line is left buffered: *tail_pos is
* not advanced past it, so it is re-read once completed. Returns true if any
* complete line was consumed (i.e. there was progress). */
static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb cb, void *ud) {
if (!log_file) {
return false;
}
FILE *lf = fopen(log_file, "r");
if (!lf) {
return false;
}
bool progressed = false;
if (fseek(lf, *tail_pos, SEEK_SET) == 0) {
char line[1024];
for (;;) {
long before = ftell(lf);
if (!fgets(line, sizeof(line), lf)) {
break;
}
size_t l = strlen(line);
bool complete = (l > 0 && line[l - 1] == '\n');
if (complete) {
line[l - 1] = '\0';
*tail_pos = ftell(lf);
progressed = true;
if (line[0] && cb) {
cb(line, ud);
}
} else if (l == sizeof(line) - 1) {
/* Oversized line filled the buffer without a newline — consume it
* anyway (counts as progress) so we never stall on one long line. */
*tail_pos = ftell(lf);
progressed = true;
if (cb) {
cb(line, ud);
}
} else {
/* Genuine partial final line — keep it buffered for next poll. */
*tail_pos = before;
break;
}
}
}
fclose(lf);
return progressed;
}
/* ── Windows command-line quoting (pure; unit-tested on every platform) ─────── */
/* Append char `c` to buf[cap], reserving the final byte for a NUL terminator.
* On overflow: sets *ovf, stops writing, and returns pos UNCHANGED — callers detect
* the overflow via the *ovf flag (not via the return value). */
static size_t cbm_cmdline_put(char *buf, size_t cap, size_t pos, char c, bool *ovf) {
if (pos + 1 >= cap) {
*ovf = true;
return pos;
}
buf[pos] = c;
return pos + 1;
}
/* Append one argv element to the command line using the Microsoft C runtime
* quoting rules (see MS "Parsing C Command-Line Arguments"). CreateProcess takes
* a SINGLE string that the child re-parses back into argv, so any element with a
* space, tab or double-quote must be wrapped in quotes and its embedded quotes /
* preceding backslashes escaped. Without this a JSON argument like
* {"repo_path":"C:/r"} loses its inner quotes and the child receives the invalid
* {repo_path:C:/r} — the Windows-only index-worker cmdline-quoting bug (the worker exited
* non-zero at JSON-arg parse, misattributed to the last-marked file). POSIX is
* unaffected: cbm_run_posix passes the argv array straight to execv. */
static size_t cbm_cmdline_append_arg(char *buf, size_t cap, size_t pos, const char *arg, bool first,
bool *ovf) {
if (!first) {
pos = cbm_cmdline_put(buf, cap, pos, ' ', ovf);
}
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
for (const char *p = arg; *p;) {
size_t nbs = 0;
while (*p == '\\') {
nbs++;
p++;
}
if (*p == '\0') {
/* Trailing backslashes precede the closing quote: double them so the
* quote stays a delimiter, not an escaped literal. */
for (size_t k = 0; k < nbs * 2; k++) {
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
}
break;
}
if (*p == '"') {
/* N backslashes then a quote -> 2N+1 backslashes then an escaped quote. */
for (size_t k = 0; k < nbs * 2 + 1; k++) {
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
}
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
p++;
} else {
for (size_t k = 0; k < nbs; k++) {
pos = cbm_cmdline_put(buf, cap, pos, '\\', ovf);
}
pos = cbm_cmdline_put(buf, cap, pos, *p, ovf);
p++;
}
}
pos = cbm_cmdline_put(buf, cap, pos, '"', ovf);
return pos;
}
/* Build a full Windows CreateProcess command line from a NULL-terminated argv,
* applying the MS C runtime quoting rules so the child re-parses byte-identical
* argv. Returns true on success, false if the result would overflow `buf`.
*
* Defined unconditionally (pure string logic, no Windows headers) so the quoting
* contract is unit-tested on Linux/macOS CI too — even though the real spawn path
* only runs on Windows. Shared by cbm_run_win AND the UI http_server index spawn
* so both escape identically; a naive `"%s"` wrap silently corrupts any argument
* containing a quote (e.g. the index JSON {"repo_path":"…"}), corrupting the
* spawned child's argv. */
bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) {
if (!buf || cap == 0 || !argv) {
return false;
}
size_t pos = 0;
bool ovf = false;
for (int i = 0; argv[i]; i++) {
pos = cbm_cmdline_append_arg(buf, cap, pos, argv[i], i == 0, &ovf);
if (ovf) {
buf[0] = '\0'; /* overflow: leave buf a valid (empty) string, never unterminated */
return false;
}
}
buf[pos] = '\0';
return true;
}
#ifdef _WIN32
static int cbm_run_win(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
const char *bin = opts->bin;
const char *const default_argv[] = {bin, NULL};
const char *const *argv = opts->argv ? opts->argv : default_argv;
char cmdline[8192];
if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), argv)) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
/* Spawn via CreateProcessW with a WIDE command line. CreateProcessA would
* re-interpret our UTF-8 cmdline bytes through the ANSI code page (CP_ACP),
* re-mangling a non-ASCII repo path at the parent->worker boundary — so the
* worker's own wide-argv read could never recover it (#423/#20). */
wchar_t *wcmd = cbm_utf8_to_wide(cmdline);
if (!wcmd) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
HANDLE hlog = INVALID_HANDLE_VALUE;
STARTUPINFOW si = {.cb = sizeof(si)};
if (opts->log_file) {
hlog = CreateFileA(opts->log_file, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, NULL);
if (hlog != INVALID_HANDLE_VALUE) {
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdError = hlog;
si.hStdOutput = hlog;
}
}
PROCESS_INFORMATION pi = {0};
BOOL ok = CreateProcessW(NULL, wcmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi);
free(wcmd);
if (hlog != INVALID_HANDLE_VALUE) {
CloseHandle(hlog);
}
if (!ok) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
long tail_pos = 0;
uint64_t last_activity = cbm_now_ms();
bool timed_out = false;
for (;;) {
DWORD w = WaitForSingleObject(pi.hProcess, 200);
if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) {
last_activity = cbm_now_ms();
}
if (w == WAIT_OBJECT_0) {
break;
}
if (opts->quiet_timeout_ms > 0 &&
(cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) {
TerminateProcess(pi.hProcess, 1);
WaitForSingleObject(pi.hProcess, INFINITE);
timed_out = true;
break;
}
}
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
if (opts->log_file && opts->delete_log_on_exit) {
DeleteFileA(opts->log_file);
}
out->exit_code = (int)code;
out->term_signal = 0;
out->outcome = cbm_proc_classify(true, (int)code, 0, timed_out);
return 0;
}
#else /* POSIX */
static int cbm_run_posix(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
pid_t pid = fork();
if (pid < 0) {
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
return -1;
}
if (pid == 0) {
/* Child: redirect stdout+stderr to the log (or discard), then exec.
* Use open()+dup2() (async-signal-safe, no malloc) rather than freopen():
* the parent may be multithreaded (the MCP server holds worker/watcher/http
* threads plus mimalloc/sqlite global state), and a fork() copies
* only the calling thread — a malloc between fork and exec could deadlock on
* a lock another thread held at fork time. open/dup2/execv touch no heap. */
const char *bin = opts->bin;
const char *const default_argv[] = {bin, NULL};
const char *const *argv = opts->argv ? opts->argv : default_argv;
const char *target = opts->log_file ? opts->log_file : "/dev/null";
int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd >= 0) {
(void)dup2(fd, STDOUT_FILENO);
(void)dup2(fd, STDERR_FILENO);
if (fd > STDERR_FILENO) {
(void)close(fd);
}
}
execv(bin, (char *const *)argv);
_exit(127); /* exec failed */
}
long tail_pos = 0;
uint64_t last_activity = cbm_now_ms();
bool timed_out = false;
int wstatus = 0;
for (;;) {
pid_t wr;
do {
wr = waitpid(pid, &wstatus, WNOHANG);
} while (wr < 0 && errno == EINTR);
bool done = (wr == pid);
if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) {
last_activity = cbm_now_ms();
}
if (done) {
break;
}
if (opts->quiet_timeout_ms > 0 &&
(cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) {
kill(pid, SIGKILL);
do {
wr = waitpid(pid, &wstatus, 0);
} while (wr < 0 && errno == EINTR);
timed_out = true;
break;
}
struct timespec ts = {0, 100000000L}; /* 100 ms poll */
cbm_nanosleep(&ts, NULL);
}
if (opts->log_file && opts->delete_log_on_exit) {
(void)unlink(opts->log_file);
}
if (WIFEXITED(wstatus)) {
out->exit_code = WEXITSTATUS(wstatus);
out->term_signal = 0;
out->outcome = cbm_proc_classify(true, out->exit_code, 0, timed_out);
} else if (WIFSIGNALED(wstatus)) {
out->exit_code = -1;
out->term_signal = WTERMSIG(wstatus);
out->outcome = cbm_proc_classify(false, -1, out->term_signal, timed_out);
} else {
out->exit_code = -1;
out->term_signal = 0;
out->outcome = timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED;
}
return 0;
}
#endif
int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) {
cbm_proc_result_t local;
if (!out) {
out = &local;
}
out->outcome = CBM_PROC_SPAWN_FAILED;
out->exit_code = -1;
out->term_signal = 0;
if (!opts || !opts->bin || !opts->bin[0]) {
return -1;
}
#ifdef _WIN32
return cbm_run_win(opts, out);
#else
return cbm_run_posix(opts, out);
#endif
}
+91
View File
@@ -0,0 +1,91 @@
/*
* subprocess.h — spawn a child process, supervise it, and classify how it ended.
*
* Generalized from the crash-isolating index spawn in src/ui/http_server.c so the
* crash/hang supervisor (Track C) can reuse one primitive across platforms.
*
* Beyond a plain spawn+wait it adds the two things a supervisor needs and the
* ad-hoc harness lacked:
* 1. Exit CLASSIFICATION — {clean, exit-nonzero, crash, hang, killed} — from
* POSIX WIFSIGNALED/WTERMSIG and the Windows NTSTATUS exception exit codes
* (0xC0000005 access-violation, 0xC00000FD stack-overflow, …).
* 2. A quiet-timeout — kill + report HANG when the child makes no progress
* (emits no new log line) for a configurable window. This catches external
* tree-sitter scanners that infinite-loop (a hang, not a crash).
*
* The reap loop is EINTR-safe. Line tailing keeps a partial final line buffered
* (an incomplete, un-newline-terminated line is not yet "progress" and is not
* mis-read as a completed marker).
*/
#ifndef CBM_SUBPROCESS_H
#define CBM_SUBPROCESS_H
#include <stdbool.h>
#include <stddef.h> /* size_t (cbm_build_win_cmdline) */
/* How a supervised child ended. */
typedef enum {
CBM_PROC_CLEAN = 0, /* exited with code 0 */
CBM_PROC_EXIT_NONZERO, /* exited with a nonzero code (a graceful failure) */
CBM_PROC_CRASH, /* died from a fault: POSIX SIGSEGV/BUS/ILL/FPE/ABRT/SYS,
* or a Windows NTSTATUS exception exit code (>= 0xC0000000) */
CBM_PROC_HANG, /* made no progress within the quiet-timeout; we killed it */
CBM_PROC_KILLED, /* terminated by a non-fault signal we did not initiate */
CBM_PROC_SPAWN_FAILED /* fork/exec/CreateProcess failed — no child ever ran */
} cbm_proc_outcome_t;
typedef struct {
cbm_proc_outcome_t outcome;
int exit_code; /* WEXITSTATUS / GetExitCodeProcess; -1 when terminated by a POSIX signal */
int term_signal; /* WTERMSIG on POSIX; 0 otherwise */
} cbm_proc_result_t;
/* Called for each newly-completed (newline-terminated) log line while the child
* runs. A completed line also resets the quiet-timeout (it is progress). */
typedef void (*cbm_proc_log_cb)(const char *line, void *ud);
typedef struct {
const char *bin; /* executable path; also argv[0] when argv is NULL */
const char *const *argv; /* NULL-terminated argv; NULL => { bin, NULL } */
const char *log_file; /* child stdout+stderr are redirected here and tailed;
* NULL => discard child output, no tailing */
cbm_proc_log_cb on_log_line; /* optional per-line callback */
void *log_ud; /* user data for on_log_line */
int quiet_timeout_ms; /* <= 0 => no timeout; else kill+HANG after this many
* ms with no new completed log line */
bool delete_log_on_exit; /* unlink log_file after reaping */
} cbm_proc_opts_t;
/* Spawn opts->bin, supervise (tail + optional quiet-timeout), block until it ends,
* and classify the result into *out. Returns 0 if a child was spawned and reaped
* (out filled), or -1 if the spawn itself failed (out->outcome == CBM_PROC_SPAWN_FAILED). */
int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out);
/* Pure outcome classifier — exposed so the platform-specific exit-code mapping
* (notably the Windows NTSTATUS crash codes) is unit-testable on every platform.
* exited_normally: the child returned an exit code (POSIX WIFEXITED; always true
* on Windows, which has no signals — crashes surface as codes).
* exit_code: the exit / exception code (meaningful when exited_normally).
* term_signal: POSIX terminating signal (meaningful when !exited_normally).
* timed_out: we killed the child for exceeding the quiet-timeout. */
cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int term_signal,
bool timed_out);
/* Stable lowercase name for an outcome (for structured logs / skip reasons). */
const char *cbm_proc_outcome_str(cbm_proc_outcome_t o);
/* Build a Windows CreateProcess command line from a NULL-terminated argv, applying
* the Microsoft C runtime quoting rules (quote-wrap + escape embedded quotes and
* their preceding backslashes) so the spawned child re-parses byte-identical argv.
* Returns true on success, false on overflow (on overflow buf is set to an empty
* string, never left unterminated).
*
* CreateProcess re-parses a SINGLE command string into argv, so a naive `"%s"` wrap
* silently corrupts any element containing a double-quote — e.g. the index worker's
* JSON arg {"repo_path":"…"} arrives as {repo_path:…}, the Windows index-worker bug.
* Exposed (and compiled on every platform — it is pure string logic) so the quoting
* is unit-tested on Linux/macOS CI, and so both spawn sites (cbm_subprocess_run and
* the UI http_server index spawn) escape through one shared, tested implementation. */
bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv);
#endif /* CBM_SUBPROCESS_H */
+306
View File
@@ -0,0 +1,306 @@
/*
* system_info.c — CPU core count and RAM detection.
*
* macOS: sysctlbyname for core counts, hw.memsize for RAM.
* BSD: sysconf + sysctl(HW_PHYSMEM64 / HW_PHYSMEM).
* Linux: sysconf + sysinfo(), with cgroup-aware overrides when running
* inside a container so the limits reflect the cgroup's effective
* CPU quota and memory cap rather than the host's totals.
* Windows: GetSystemInfo + GlobalMemoryStatusEx.
*
* Results are cached after first call (immutable hardware properties).
*/
#include "foundation/constants.h"
enum { DEFAULT_CORES = 1, MIN_WORKERS = 1, CBM_WORKERS_MAX = 256 };
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/system_info_internal.h"
#include <stdint.h> // uint64_t
#include <stdlib.h> // strtol
#include <string.h>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#elif defined(__APPLE__)
#include <sys/sysctl.h>
#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)
#include <unistd.h>
#include <sys/types.h>
#include <sys/sysctl.h>
#else /* Linux */
/* limits.h for ULLONG_MAX, stdio.h for fopen/fread, stdlib.h for strto*. */
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/sysinfo.h>
#include <unistd.h>
#endif
/* ── macOS detection ─────────────────────────────────────────────── */
#ifdef __APPLE__
static int sysctl_int(const char *name, int fallback) {
int val = 0;
size_t len = sizeof(val);
if (sysctlbyname(name, &val, &len, NULL, 0) == 0 && val > 0) {
return val;
}
return fallback;
}
static size_t sysctl_size(const char *name, size_t fallback) {
size_t val = 0;
size_t len = sizeof(val);
if (sysctlbyname(name, &val, &len, NULL, 0) == 0 && val > 0) {
return val;
}
/* Try CBM_SZ_64-bit variant */
uint64_t val64 = 0;
len = sizeof(val64);
if (sysctlbyname(name, &val64, &len, NULL, 0) == 0 && val64 > 0) {
return (size_t)val64;
}
return fallback;
}
static cbm_system_info_t detect_system_macos(void) {
cbm_system_info_t info;
memset(&info, 0, sizeof(info));
info.total_cores = sysctl_int("hw.ncpu", DEFAULT_CORES);
info.perf_cores = sysctl_int("hw.perflevel0.physicalcpu", info.total_cores);
/* If perflevel sysctls fail (Intel Mac), perf = total */
int eff = sysctl_int("hw.perflevel1.physicalcpu", 0);
if (info.perf_cores + eff > info.total_cores) {
info.perf_cores = info.total_cores;
}
info.total_ram = sysctl_size("hw.memsize", 0);
return info;
}
#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)
static cbm_system_info_t detect_system_bsd(void) {
cbm_system_info_t info;
memset(&info, 0, sizeof(info));
long nprocs = sysconf(_SC_NPROCESSORS_ONLN);
info.total_cores = nprocs > 0 ? (int)nprocs : 1;
info.perf_cores = info.total_cores;
#if defined(__OpenBSD__)
int mib[2] = {CTL_HW, HW_PHYSMEM};
#else
int mib[2] = {CTL_HW, HW_PHYSMEM64};
#endif
uint64_t physmem = 0;
size_t len = sizeof(physmem);
if (sysctl(mib, 2, &physmem, &len, NULL, 0) == 0 && physmem > 0) {
info.total_ram = (size_t)physmem;
}
return info;
}
#elif !defined(_WIN32) /* Linux */
/* Read up to (bufsz-1) bytes from `path` into `buf`, NUL-terminate, and strip
* trailing whitespace. Returns the (stripped) byte count, or -1 if the file
* could not be opened or read. */
static int read_small_file(const char *path, char *buf, size_t bufsz) {
FILE *fp = fopen(path, "re");
if (fp == NULL) {
return -1;
}
size_t n = fread(buf, 1, bufsz - 1, fp);
fclose(fp);
while (n > 0 && (buf[n - 1] == '\n' || buf[n - 1] == ' ' || buf[n - 1] == '\t')) {
n--;
}
buf[n] = '\0';
return (int)n;
}
/* Effective CPU count from a cgroup file tree. See header for contract. */
int cbm_detect_cgroup_cpus(const char *cgroup_root) {
char path[CBM_PATH_MAX];
char buf[CBM_SZ_64];
/* cgroup v2: "<root>/cpu.max" — "<quota> <period>" or "max <period>". */
snprintf(path, sizeof(path), "%s/cpu.max", cgroup_root);
if (read_small_file(path, buf, sizeof(buf)) > 0) {
if (strncmp(buf, "max", 3) == 0) {
return -1; /* no quota → caller falls back to sysconf */
}
long quota = 0;
long period = 0;
if (sscanf(buf, "%ld %ld", &quota, &period) == 2 && quota > 0 && period > 0) {
long n = (quota + period - 1) / period; /* ceil(quota/period) */
return n > 0 ? (int)n : MIN_WORKERS;
}
return -1;
}
/* cgroup v1: ".../cpu/cpu.cfs_quota_us" and ".../cpu/cpu.cfs_period_us".
* A quota of -1 means unlimited in cgroup v1. */
snprintf(path, sizeof(path), "%s/cpu/cpu.cfs_quota_us", cgroup_root);
if (read_small_file(path, buf, sizeof(buf)) <= 0) {
return -1;
}
long quota = strtol(buf, NULL, CBM_DECIMAL_BASE);
if (quota <= 0) {
return -1;
}
snprintf(path, sizeof(path), "%s/cpu/cpu.cfs_period_us", cgroup_root);
if (read_small_file(path, buf, sizeof(buf)) <= 0) {
return -1;
}
long period = strtol(buf, NULL, CBM_DECIMAL_BASE);
if (period <= 0) {
return -1;
}
long n = (quota + period - 1) / period;
return n > 0 ? (int)n : MIN_WORKERS;
}
/* Effective memory limit from a cgroup file tree. See header for contract. */
size_t cbm_detect_cgroup_mem(const char *cgroup_root) {
char path[CBM_PATH_MAX];
char buf[CBM_SZ_64];
/* cgroup v2: "<root>/memory.max" — "max" or integer bytes. */
snprintf(path, sizeof(path), "%s/memory.max", cgroup_root);
if (read_small_file(path, buf, sizeof(buf)) > 0) {
if (strncmp(buf, "max", 3) == 0) {
return 0;
}
char *end = NULL;
unsigned long long n = strtoull(buf, &end, CBM_DECIMAL_BASE);
if (end == buf || n == 0) {
return 0;
}
return (size_t)n;
}
/* cgroup v1: ".../memory/memory.limit_in_bytes". The sentinel for
* "unlimited" is a very large value (~PAGE_COUNTER_MAX); treat anything
* past half of ULLONG_MAX as effectively unlimited. */
snprintf(path, sizeof(path), "%s/memory/memory.limit_in_bytes", cgroup_root);
if (read_small_file(path, buf, sizeof(buf)) <= 0) {
return 0;
}
char *end = NULL;
unsigned long long n = strtoull(buf, &end, CBM_DECIMAL_BASE);
if (end == buf || n == 0 || n >= (ULLONG_MAX / 2)) {
return 0;
}
return (size_t)n;
}
static cbm_system_info_t detect_system_linux(void) {
cbm_system_info_t info;
memset(&info, 0, sizeof(info));
/* Host fallbacks. */
long nprocs = sysconf(_SC_NPROCESSORS_ONLN);
int host_cpus = nprocs > 0 ? (int)nprocs : DEFAULT_CORES;
size_t host_ram = 0;
struct sysinfo si;
if (sysinfo(&si) == 0) {
host_ram = (size_t)si.totalram * (size_t)si.mem_unit;
}
/* Cgroup-aware overrides. min(cgroup, host) defends against
* mis-mounted cgroups that report values larger than the host. */
int cg_cpus = cbm_detect_cgroup_cpus("/sys/fs/cgroup");
info.total_cores = (cg_cpus > 0 && cg_cpus < host_cpus) ? cg_cpus : host_cpus;
info.perf_cores = info.total_cores; /* Linux doesn't distinguish P/E */
size_t cg_ram = cbm_detect_cgroup_mem("/sys/fs/cgroup");
info.total_ram = (cg_ram > 0 && (host_ram == 0 || cg_ram < host_ram)) ? cg_ram : host_ram;
return info;
}
#endif /* __APPLE__ / BSD / Linux */
/* ── Windows detection ───────────────────────────────────────────── */
#ifdef _WIN32
static cbm_system_info_t detect_system_windows(void) {
cbm_system_info_t info;
memset(&info, 0, sizeof(info));
SYSTEM_INFO si;
GetSystemInfo(&si);
info.total_cores = (int)si.dwNumberOfProcessors;
if (info.total_cores < 1) {
info.total_cores = SKIP_ONE;
}
info.perf_cores = info.total_cores;
MEMORYSTATUSEX ms;
ms.dwLength = sizeof(ms);
if (GlobalMemoryStatusEx(&ms)) {
info.total_ram = (size_t)ms.ullTotalPhys;
}
return info;
}
#endif
/* ── Public API ──────────────────────────────────────────────────── */
static int info_cached = 0;
static cbm_system_info_t cached_info;
cbm_system_info_t cbm_system_info(void) {
if (!info_cached) {
#ifdef _WIN32
cached_info = detect_system_windows();
#elif defined(__APPLE__)
cached_info = detect_system_macos();
#elif defined(__NetBSD__) || defined(__FreeBSD__) || defined(__OpenBSD__)
cached_info = detect_system_bsd();
#else
cached_info = detect_system_linux();
#endif
info_cached = SKIP_ONE;
}
return cached_info;
}
int cbm_default_worker_count(bool initial) {
/* CBM_WORKERS env override (clamped to [1, CBM_WORKERS_MAX]).
* Useful inside containers where sysconf(_SC_NPROCESSORS_ONLN)
* reports host CPUs rather than the cgroup's effective CPU quota.
* Same precedence shape as other CBM_* env overrides:
* explicit override > implicit detection. */
char buf[CBM_SZ_32];
if (cbm_safe_getenv("CBM_WORKERS", buf, sizeof(buf), NULL) != NULL) {
long n = strtol(buf, NULL, CBM_DECIMAL_BASE);
if (n >= MIN_WORKERS && n <= CBM_WORKERS_MAX) {
return (int)n;
}
cbm_log_warn("workers.env.invalid", "value", buf, "fallback", "sysconf");
}
cbm_system_info_t info = cbm_system_info();
if (initial) {
/* Use all cores for initial indexing — user is waiting */
return info.total_cores;
}
/* Incremental: leave headroom for user's apps */
int workers = info.perf_cores - SKIP_ONE;
return workers > 0 ? workers : MIN_WORKERS;
}
+44
View File
@@ -0,0 +1,44 @@
/*
* system_info_internal.h — Internal helpers exposed for testing.
*
* These functions are implementation details of system_info.c; they are
* declared here only so that test_platform.c can drive them against a
* fake cgroup filesystem. Production code outside system_info.c should
* use the public APIs in platform.h instead.
*/
#ifndef CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H
#define CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H
#include <stddef.h>
#ifdef __linux__
/*
* Effective CPU count for the cgroup rooted at `cgroup_root`.
*
* Reads (in order):
* 1. cgroup v2: "<cgroup_root>/cpu.max" ("<quota> <period>" or "max ...")
* 2. cgroup v1: "<cgroup_root>/cpu/cpu.cfs_quota_us" + ".../cpu.cfs_period_us"
*
* Returns ceil(quota / period) (>= 1) when a valid CPU quota is in place.
* Returns -1 when no cgroup limit is present (caller should fall back to
* sysconf(_SC_NPROCESSORS_ONLN)).
*/
int cbm_detect_cgroup_cpus(const char *cgroup_root);
/*
* Effective memory limit (bytes) for the cgroup rooted at `cgroup_root`.
*
* Reads (in order):
* 1. cgroup v2: "<cgroup_root>/memory.max" ("max" or integer bytes)
* 2. cgroup v1: "<cgroup_root>/memory/memory.limit_in_bytes"
*
* Returns the byte count when a finite limit is in place. Returns 0 when
* no cgroup limit is present, the limit is "max"/unlimited, or the value
* is so large it represents the cgroup-v1 "unlimited" sentinel.
*/
size_t cbm_detect_cgroup_mem(const char *cgroup_root);
#endif /* __linux__ */
#endif /* CBM_FOUNDATION_SYSTEM_INFO_INTERNAL_H */
+196
View File
@@ -0,0 +1,196 @@
/*
* vmem.c — Budget-tracked virtual memory allocator.
*
* Allocates via mmap (POSIX) or VirtualAlloc (Windows) to bypass
* ptmalloc2's per-thread arena fragmentation. All allocations are
* page-aligned, zeroed by the OS, and tracked against a configurable
* budget (fraction of physical RAM).
*
* Pressure events are logged with hysteresis to avoid log storms
* near the budget boundary.
*/
#include "vmem.h"
#include "platform.h"
#include "log.h"
#include <stdatomic.h>
#include <stdio.h> /* snprintf */
#include <string.h> /* memset */
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#else
#include <sys/mman.h>
#include <unistd.h> /* sysconf, _SC_PAGESIZE */
#endif
/* ── Static state (initialized once) ──────────────────────────── */
static atomic_size_t g_allocated = 0; /* current total allocated */
static atomic_size_t g_peak = 0; /* high-water mark */
static size_t g_budget = 0; /* budget in bytes */
static atomic_int g_was_over = 0; /* hysteresis: was over budget? */
static atomic_int g_initialized = 0; /* init guard */
/* ── Page size ─────────────────────────────────────────────────── */
static size_t page_size(void) {
#ifdef _WIN32
SYSTEM_INFO si;
GetSystemInfo(&si);
return (size_t)si.dwPageSize;
#else
long ps = sysconf(_SC_PAGESIZE);
return ps > 0 ? (size_t)ps : 4096;
#endif
}
/* Round up to page boundary. */
static size_t round_to_page(size_t size) {
size_t ps = page_size();
return (size + ps - SKIP_ONE) & ~(ps - SKIP_ONE);
}
/* ── Pressure logging ──────────────────────────────────────────── */
#define MB_DIVISOR ((size_t)(CBM_SZ_1K * CBM_SZ_1K))
static void check_pressure(size_t allocated) {
if (g_budget == 0) {
return;
}
bool over = allocated > g_budget;
int was = atomic_load(&g_was_over);
if (over && !was) {
/* Transition: under → over */
atomic_store(&g_was_over, 1);
char alloc_mb[CBM_SZ_32];
char budget_mb[CBM_SZ_32];
char pct_str[CBM_SZ_16];
snprintf(alloc_mb, sizeof(alloc_mb), "%zu", allocated / MB_DIVISOR);
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
snprintf(pct_str, sizeof(pct_str), "%zu",
g_budget > 0 ? (allocated * CBM_PERCENT) / g_budget : 0);
cbm_log_warn("mem.pressure.warn", "allocated_mb", alloc_mb, "budget_mb", budget_mb, "pct",
pct_str);
} else if (!over && was) {
/* Transition: over → under */
atomic_store(&g_was_over, 0);
char alloc_mb[CBM_SZ_32];
char budget_mb[CBM_SZ_32];
char pct_str[CBM_SZ_16];
snprintf(alloc_mb, sizeof(alloc_mb), "%zu", allocated / MB_DIVISOR);
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
snprintf(pct_str, sizeof(pct_str), "%zu",
g_budget > 0 ? (allocated * CBM_PERCENT) / g_budget : 0);
cbm_log_info("mem.pressure.ok", "allocated_mb", alloc_mb, "budget_mb", budget_mb, "pct",
pct_str);
}
}
/* ── Update peak ───────────────────────────────────────────────── */
static void update_peak(size_t allocated) {
size_t old_peak = atomic_load(&g_peak);
while (allocated > old_peak) {
if (atomic_compare_exchange_weak(&g_peak, &old_peak, allocated)) {
break;
}
}
}
/* ── Public API ────────────────────────────────────────────────── */
void cbm_vmem_init(double ram_fraction) {
/* Only first call takes effect */
int expected = 0;
if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) {
return;
}
if (ram_fraction <= 0.0 || ram_fraction > 1.0) {
ram_fraction = 0.5;
}
cbm_system_info_t info = cbm_system_info();
g_budget = (size_t)((double)info.total_ram * ram_fraction);
char budget_mb[CBM_SZ_32];
char ram_mb[CBM_SZ_32];
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
snprintf(ram_mb, sizeof(ram_mb), "%zu", info.total_ram / MB_DIVISOR);
cbm_log_info("vmem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb);
}
void *cbm_vmem_alloc(size_t size) {
if (size == 0) {
return NULL;
}
size_t alloc_size = round_to_page(size);
#ifdef _WIN32
void *ptr = VirtualAlloc(NULL, alloc_size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
void *ptr = mmap(NULL, alloc_size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED) {
ptr = NULL;
}
#endif
if (!ptr) {
cbm_log_error("vmem.alloc.fail", "size_mb", size > MB_DIVISOR ? "large" : "small");
return NULL;
}
size_t new_total = atomic_fetch_add(&g_allocated, alloc_size) + alloc_size;
update_peak(new_total);
check_pressure(new_total);
return ptr;
}
void cbm_vmem_free(void *ptr, size_t size) {
if (!ptr || size == 0) {
return;
}
size_t free_size = round_to_page(size);
#ifdef _WIN32
VirtualFree(ptr, 0, MEM_RELEASE);
#else
munmap(ptr, free_size);
#endif
size_t new_total = atomic_fetch_sub(&g_allocated, free_size) - free_size;
check_pressure(new_total);
}
size_t cbm_vmem_allocated(void) {
return atomic_load(&g_allocated);
}
size_t cbm_vmem_peak(void) {
return atomic_load(&g_peak);
}
size_t cbm_vmem_budget(void) {
return g_budget;
}
bool cbm_vmem_over_budget(void) {
return atomic_load(&g_allocated) > g_budget;
}
size_t cbm_vmem_worker_budget(int num_workers) {
if (num_workers <= 0) {
num_workers = SKIP_ONE;
}
return g_budget / (size_t)num_workers;
}
+50
View File
@@ -0,0 +1,50 @@
/*
* vmem.h — Budget-tracked virtual memory allocator.
*
* Allocates via mmap/VirtualAlloc instead of malloc to avoid ptmalloc2's
* per-thread arena fragmentation (the cause of 321GB VSZ on Linux kernel
* indexing with 12 workers).
*
* Budget: configurable fraction of physical RAM (default 50%). When
* allocation exceeds the budget, a pressure event is logged. Workers
* never block — the budget is advisory for monitoring/alerting only.
*
* All allocations are page-aligned and zeroed by the OS.
*/
#ifndef CBM_VMEM_H
#define CBM_VMEM_H
#include <stdbool.h>
#include <stddef.h>
/* Initialize vmem with a budget = ram_fraction * total_physical_ram.
* Thread-safe: only the first call takes effect.
* Must be called before any cbm_vmem_alloc() calls. */
void cbm_vmem_init(double ram_fraction);
/* Allocate size bytes via mmap/VirtualAlloc.
* Returns page-aligned, zeroed memory. NULL on failure.
* Tracks allocation against the budget and logs pressure events. */
void *cbm_vmem_alloc(size_t size);
/* Free memory previously allocated by cbm_vmem_alloc.
* size must match the original allocation size. */
void cbm_vmem_free(void *ptr, size_t size);
/* Current total allocated bytes (atomic). */
size_t cbm_vmem_allocated(void);
/* Peak allocated bytes seen so far. */
size_t cbm_vmem_peak(void);
/* Total budget in bytes. */
size_t cbm_vmem_budget(void);
/* Returns true if current allocation exceeds the budget. */
bool cbm_vmem_over_budget(void);
/* Per-worker budget hint: budget / num_workers.
* For monitoring/logging only — workers never block. */
size_t cbm_vmem_worker_budget(int num_workers);
#endif /* CBM_VMEM_H */
+44
View File
@@ -0,0 +1,44 @@
#ifndef CBM_WIN_UTF8_H
#define CBM_WIN_UTF8_H
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <stdlib.h>
#include <wchar.h>
static inline wchar_t *cbm_utf8_to_wide(const char *utf8) {
if (!utf8) {
return NULL;
}
int len = MultiByteToWideChar(CP_UTF8, 0, utf8, -1, NULL, 0);
if (len <= 0) {
return NULL;
}
wchar_t *w = (wchar_t *)malloc((size_t)len * sizeof(wchar_t));
if (w) {
MultiByteToWideChar(CP_UTF8, 0, utf8, -1, w, len);
}
return w;
}
static inline char *cbm_wide_to_utf8(const wchar_t *wide) {
if (!wide) {
return NULL;
}
int len = WideCharToMultiByte(CP_UTF8, 0, wide, -1, NULL, 0, NULL, NULL);
if (len <= 0) {
return NULL;
}
char *u8 = (char *)malloc((size_t)len);
if (u8) {
WideCharToMultiByte(CP_UTF8, 0, wide, -1, u8, len, NULL, NULL);
}
return u8;
}
#endif /* _WIN32 */
#endif /* CBM_WIN_UTF8_H */
+423
View File
@@ -0,0 +1,423 @@
/*
* yaml.c — Minimal YAML parser for config files.
*
* Strategy: line-by-line parsing with indentation tracking.
* Each line is either:
* - Empty or comment (#) → skip
* - "key: value" → scalar entry in current map
* - "key:" (no value) → start of nested map or list
* - "- value" → list item
*
* Indentation determines nesting depth. Uses a stack of parent nodes.
*/
#include "foundation/constants.h"
enum { YAML_INIT_CAP = 8, YAML_LIST_PREFIX = 2, YAML_ROOT_INDENT = -1 };
#include "foundation/yaml.h"
#include <ctype.h>
#include <stddef.h> // NULL
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Node types ───────────────────────────────────────────────── */
typedef enum {
YAML_MAP,
YAML_LIST,
YAML_SCALAR,
} yaml_type_t;
struct cbm_yaml_node {
yaml_type_t type;
char *key; /* NULL for list items and root */
char *value; /* scalar value (NULL for map/list) */
/* Children (for map and list nodes) */
cbm_yaml_node_t **children;
int child_count;
int child_cap;
};
/* ── Node lifecycle ───────────────────────────────────────────── */
static cbm_yaml_node_t *node_new(yaml_type_t type) {
cbm_yaml_node_t *n = calloc(CBM_ALLOC_ONE, sizeof(*n));
if (n) {
n->type = type;
}
return n;
}
static bool node_add_child(cbm_yaml_node_t *parent, cbm_yaml_node_t *child) {
if (!parent || !child) {
return false;
}
if (parent->child_count >= parent->child_cap) {
int new_cap =
parent->child_cap < YAML_INIT_CAP ? YAML_INIT_CAP : parent->child_cap * PAIR_LEN;
cbm_yaml_node_t **new_arr = realloc(parent->children, (size_t)new_cap * sizeof(*new_arr));
if (!new_arr) {
return false;
}
parent->children = new_arr;
parent->child_cap = new_cap;
}
parent->children[parent->child_count++] = child;
return true;
}
void cbm_yaml_free(cbm_yaml_node_t *root) {
if (!root) {
return;
}
enum { YAML_FREE_STACK = 256 };
cbm_yaml_node_t *stack[YAML_FREE_STACK];
int top = 0;
stack[top++] = root;
while (top > 0) {
cbm_yaml_node_t *node = stack[--top];
for (int i = 0; i < node->child_count && top < YAML_FREE_STACK; i++) {
if (node->children[i]) {
stack[top++] = node->children[i];
}
}
free(node->children);
free(node->key);
free(node->value);
free(node);
}
}
/* ── String helpers ───────────────────────────────────────────── */
/* Duplicate a substring [start, end). Trims trailing whitespace. */
static char *trim_dup(const char *start, const char *end) {
while (end > start && isspace((unsigned char)end[-SKIP_ONE])) {
end--;
}
while (start < end && isspace((unsigned char)*start)) {
start++;
}
if (start >= end) {
return strdup("");
}
size_t len = (size_t)(end - start);
char *s = malloc(len + SKIP_ONE);
if (!s) {
return NULL;
}
memcpy(s, start, len);
s[len] = '\0';
return s;
}
/* Count leading spaces (not tabs). */
static int leading_spaces(const char *line) {
int n = 0;
while (line[n] == ' ') {
n++;
}
return n;
}
/* ── Parser ───────────────────────────────────────────────────── */
/* Stack entry for tracking parent context during parsing. */
typedef struct {
cbm_yaml_node_t *node;
int indent;
} stack_entry_t;
/* Parse a list item "- value" and add to the correct parent. */
static void parse_list_item(const char *content, int content_len, cbm_yaml_node_t *parent) {
const char *item_start = content + YAML_LIST_PREFIX;
int item_len = content_len - YAML_LIST_PREFIX;
while (item_len > 0 && isspace((unsigned char)item_start[0])) {
item_start++;
item_len--;
}
/* If parent is a map, the list items belong to the last child */
cbm_yaml_node_t *list_parent = parent;
if (parent->type == YAML_MAP && parent->child_count > 0) {
cbm_yaml_node_t *last = parent->children[parent->child_count - SKIP_ONE];
if (last->type == YAML_LIST) {
list_parent = last;
}
}
cbm_yaml_node_t *item = node_new(YAML_SCALAR);
if (item) {
item->value = trim_dup(item_start, item_start + item_len);
if (!node_add_child(list_parent, item)) {
cbm_yaml_free(item);
}
}
}
/* Strip inline comments from a value string (not inside quotes).
* Returns the adjusted length. */
static int strip_inline_comment(const char *after, int after_len) {
if (after_len > 0 && after[0] != '"' && after[0] != '\'') {
for (int i = 0; i < after_len; i++) {
if (after[i] == '#' && i > 0 && after[i - SKIP_ONE] == ' ') {
after_len = i;
while (after_len > 0 && isspace((unsigned char)after[after_len - SKIP_ONE])) {
after_len--;
}
break;
}
}
}
return after_len;
}
/* Peek ahead to check if next content line is a list item. */
static bool peek_is_list(const char *eol, const char *end) {
const char *peek = (eol < end) ? eol + SKIP_ONE : end;
while (peek < end) {
const char *peek_eol = memchr(peek, '\n', (size_t)(end - peek));
if (!peek_eol) {
peek_eol = end;
}
int pi = leading_spaces(peek);
const char *pc = peek + pi;
int pcl = (int)(peek_eol - pc);
if (pcl > 0 && pc[pcl - SKIP_ONE] == '\r') {
pcl--;
}
if (pcl == 0 || pc[0] == '#') {
peek = (peek_eol < end) ? peek_eol + SKIP_ONE : end;
continue;
}
return pc[0] == '-';
}
return false;
}
/* Parse a "key: value" or "key:" line. */
static void parse_key_line(const char *content, int content_len, int indent,
cbm_yaml_node_t *parent, stack_entry_t *stack, int *stack_depth,
const char *eol, const char *end) {
const char *colon = memchr(content, ':', (size_t)content_len);
if (!colon) {
return;
}
char *key = trim_dup(content, colon);
if (!key) {
return;
}
/* After colon: value or nothing */
const char *after = colon + SKIP_ONE;
int after_len = content_len - (int)(after - content);
while (after_len > 0 && isspace((unsigned char)*after)) {
after++;
after_len--;
}
after_len = strip_inline_comment(after, after_len);
if (after_len > 0) {
/* "key: value" — scalar */
cbm_yaml_node_t *child = node_new(YAML_SCALAR);
if (child) {
child->key = key;
child->value = trim_dup(after, after + after_len);
if (!node_add_child(parent, child)) {
cbm_yaml_free(child);
}
} else {
free(key);
}
} else {
/* "key:" — could be map or list (determined by next lines) */
bool is_list = peek_is_list(eol, end);
cbm_yaml_node_t *child = node_new(is_list ? YAML_LIST : YAML_MAP);
if (child) {
child->key = key;
if (!node_add_child(parent, child)) {
cbm_yaml_free(child);
} else if (*stack_depth < CBM_SZ_32) {
stack[(*stack_depth)++] = (stack_entry_t){.node = child, .indent = indent};
}
} else {
free(key);
}
}
}
cbm_yaml_node_t *cbm_yaml_parse(const char *text, int len) {
if (!text || len <= 0) {
return node_new(YAML_MAP);
}
cbm_yaml_node_t *root = node_new(YAML_MAP);
if (!root) {
return NULL;
}
/* Stack for tracking parent context */
stack_entry_t stack[CBM_SZ_32];
int stack_depth = 0;
stack[0] = (stack_entry_t){.node = root, .indent = YAML_ROOT_INDENT};
stack_depth = SKIP_ONE;
const char *p = text;
const char *end = text + len;
while (p < end) {
/* Find end of line */
const char *eol = memchr(p, '\n', (size_t)(end - p));
if (!eol) {
eol = end;
}
int line_len = (int)(eol - p);
/* Skip empty lines and comments */
int indent = leading_spaces(p);
const char *content = p + indent;
int content_len = line_len - indent;
/* Strip \r */
if (content_len > 0 && content[content_len - SKIP_ONE] == '\r') {
content_len--;
}
if (content_len == 0 || content[0] == '#') {
p = (eol < end) ? eol + SKIP_ONE : end;
continue;
}
/* Pop stack to find parent at correct indentation */
while (stack_depth > SKIP_ONE && stack[stack_depth - SKIP_ONE].indent >= indent) {
stack_depth--;
}
cbm_yaml_node_t *parent = stack[stack_depth - SKIP_ONE].node;
if (content[0] == '-' && content_len >= PAIR_LEN && content[SKIP_ONE] == ' ') {
parse_list_item(content, content_len, parent);
} else {
parse_key_line(content, content_len, indent, parent, stack, &stack_depth, eol, end);
}
p = (eol < end) ? eol + SKIP_ONE : end;
}
return root;
}
/* ── Query helpers ────────────────────────────────────────────── */
/* Find a child node by key in a map node. */
static const cbm_yaml_node_t *find_child(const cbm_yaml_node_t *node, const char *key) {
if (!node || !key) {
return NULL;
}
for (int i = 0; i < node->child_count; i++) {
if (node->children[i]->key && strcmp(node->children[i]->key, key) == 0) {
return node->children[i];
}
}
return NULL;
}
/* Navigate to a node by dot-separated path. */
static const cbm_yaml_node_t *navigate(const cbm_yaml_node_t *root, const char *path) {
if (!root || !path) {
return NULL;
}
const cbm_yaml_node_t *cur = root;
char buf[CBM_SZ_256];
const char *p = path;
while (*p) {
const char *dot = strchr(p, '.');
int seg_len;
if (dot) {
seg_len = (int)(dot - p);
} else {
seg_len = (int)strlen(p);
}
if (seg_len <= 0 || seg_len >= (int)sizeof(buf)) {
return NULL;
}
memcpy(buf, p, (size_t)seg_len);
buf[seg_len] = '\0';
cur = find_child(cur, buf);
if (!cur) {
return NULL;
}
p += seg_len;
if (*p == '.') {
p++;
}
}
return cur;
}
const char *cbm_yaml_get_str(const cbm_yaml_node_t *root, const char *path) {
const cbm_yaml_node_t *node = navigate(root, path);
if (!node || node->type != YAML_SCALAR) {
return NULL;
}
return node->value;
}
double cbm_yaml_get_float(const cbm_yaml_node_t *root, const char *path, double default_val) {
const char *str = cbm_yaml_get_str(root, path);
if (!str) {
return default_val;
}
char *endptr;
double val = strtod(str, &endptr);
if (endptr == str) {
return default_val;
}
return val;
}
bool cbm_yaml_get_bool(const cbm_yaml_node_t *root, const char *path, bool default_val) {
const char *str = cbm_yaml_get_str(root, path);
if (!str) {
return default_val;
}
if (strcasecmp(str, "true") == 0 || strcasecmp(str, "yes") == 0 || strcasecmp(str, "on") == 0 ||
strcmp(str, "1") == 0) {
return true;
}
if (strcasecmp(str, "false") == 0 || strcasecmp(str, "no") == 0 ||
strcasecmp(str, "off") == 0 || strcmp(str, "0") == 0) {
return false;
}
return default_val;
}
int cbm_yaml_get_str_list(const cbm_yaml_node_t *root, const char *path, const char **out,
int max_out) {
const cbm_yaml_node_t *node = navigate(root, path);
if (!node || node->type != YAML_LIST) {
return 0;
}
int count = 0;
for (int i = 0; i < node->child_count && count < max_out; i++) {
if (node->children[i]->type == YAML_SCALAR && node->children[i]->value) {
out[count++] = node->children[i]->value;
}
}
return count;
}
bool cbm_yaml_has(const cbm_yaml_node_t *root, const char *path) {
return navigate(root, path) != NULL;
}
+46
View File
@@ -0,0 +1,46 @@
/*
* yaml.h — Minimal YAML parser for config files.
*
* Handles the subset needed by .cgrconfig:
* - key: value pairs (string, float, bool)
* - Nested maps (indentation-based)
* - String lists (- item)
* - Comment lines (#)
*
* NOT a general YAML parser — no multiline strings, anchors, flow style, etc.
*/
#ifndef CBM_YAML_H
#define CBM_YAML_H
#include <stdbool.h>
typedef struct cbm_yaml_node cbm_yaml_node_t;
/* Parse a YAML string into a tree. Returns NULL on error.
* Caller must free with cbm_yaml_free(). */
cbm_yaml_node_t *cbm_yaml_parse(const char *text, int len);
/* Free a parsed YAML tree. */
void cbm_yaml_free(cbm_yaml_node_t *root);
/* Get a scalar value by dot-separated path (e.g. "http_linker.min_confidence").
* Returns NULL if not found or not a scalar. */
const char *cbm_yaml_get_str(const cbm_yaml_node_t *root, const char *path);
/* Get a float value by path, returning default_val if not found. */
double cbm_yaml_get_float(const cbm_yaml_node_t *root, const char *path, double default_val);
/* Get a bool value by path, returning default_val if not found.
* Recognizes: true/false, yes/no, on/off (case-insensitive). */
bool cbm_yaml_get_bool(const cbm_yaml_node_t *root, const char *path, bool default_val);
/* Get a list of string values at a path (e.g. "http_linker.exclude_paths").
* Writes up to max_out pointers into out[]. Pointers are owned by the YAML tree.
* Returns count of items written. */
int cbm_yaml_get_str_list(const cbm_yaml_node_t *root, const char *path, const char **out,
int max_out);
/* Check if a node at the given path exists. */
bool cbm_yaml_has(const cbm_yaml_node_t *root, const char *path);
#endif /* CBM_YAML_H */
+421
View File
@@ -0,0 +1,421 @@
#include "git/git_context.h"
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/str_util.h"
#include <ctype.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
enum {
GIT_CMD_MAX = 1024,
GIT_OUTPUT_MAX = 4096,
};
static char *git_strdup(const char *s) {
if (!s) {
s = "";
}
size_t n = strlen(s) + 1;
char *out = (char *)malloc(n);
if (!out) {
return NULL;
}
memcpy(out, s, n);
return out;
}
static void trim_newlines(char *s) {
if (!s) {
return;
}
size_t n = strlen(s);
while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r')) {
s[--n] = '\0';
}
}
static bool git_validate_repo_path(const char *repo_path) {
if (!cbm_validate_shell_arg(repo_path)) {
return false;
}
#ifdef _WIN32
for (const char *p = repo_path; *p; p++) {
if (*p == '%' || *p == '!' || *p == '^') {
return false;
}
}
#endif
return true;
}
static int git_capture(const char *repo_path, const char *git_args, char **out) {
if (!out) {
return CBM_NOT_FOUND;
}
*out = NULL;
if (!repo_path || !git_args || !git_validate_repo_path(repo_path)) {
return CBM_NOT_FOUND;
}
char cmd[GIT_CMD_MAX];
#ifdef _WIN32
const char *null_dev = "NUL";
#else
const char *null_dev = "/dev/null";
#endif
/* Double quotes work for POSIX shells and cmd.exe. cbm_validate_shell_arg()
* rejects quote/backslash/substitution metacharacters before interpolation. */
int n = snprintf(cmd, sizeof(cmd), "git -C \"%s\" %s 2>%s", repo_path, git_args, null_dev);
if (n < 0 || n >= (int)sizeof(cmd)) {
return CBM_NOT_FOUND;
}
FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
return CBM_NOT_FOUND;
}
char buf[GIT_OUTPUT_MAX];
if (!fgets(buf, sizeof(buf), fp)) {
cbm_pclose(fp);
return CBM_NOT_FOUND;
}
trim_newlines(buf);
int rc = cbm_pclose(fp);
if (rc != 0 || buf[0] == '\0') {
return CBM_NOT_FOUND;
}
*out = git_strdup(buf);
return *out ? 0 : CBM_NOT_FOUND;
}
static bool path_is_absolute(const char *path) {
if (!path || !path[0]) {
return false;
}
if (path[0] == '/') {
return true;
}
#ifdef _WIN32
return isalpha((unsigned char)path[0]) && path[1] == ':';
#else
return false;
#endif
}
static char *join_root_relative(const char *root, const char *rel) {
if (!root || !root[0]) {
return git_strdup(rel);
}
int n = snprintf(NULL, 0, "%s/%s", root, rel);
if (n < 0) {
return NULL;
}
char *out = (char *)malloc((size_t)n + 1);
if (!out) {
return NULL;
}
snprintf(out, (size_t)n + 1, "%s/%s", root, rel);
return out;
}
/* Derive the canonical repo root.
*
* Preferred (git 2.31+): abs_common_dir is `git rev-parse --path-format=absolute
* --git-common-dir` — git's OWN absolute, canonical common-dir. Because a main
* repo and its linked worktree both ask the same git binary, they resolve to the
* IDENTICAL path, so canonical_root is consistent across worktrees AND platforms
* with no manual join or realpath/_fullpath — which diverged under msys vs native
* path representations (#659 root cause, and the worktree==main-root breakage in
* test_pipeline.c git_context_linked_worktree). git also resolves the relative
* ".." internally, so the subdirectory case (#659) is fixed here too.
*
* Fallback (git < 2.31, no --path-format → abs_common_dir empty): the relative
* --git-common-dir is relative to input_path (the -C dir), so join against it and
* realpath-normalize the "..". Unix only — on Windows git emits an absolute
* common-dir so this branch isn't reached in practice, and _fullpath there
* reintroduces the msys divergence. */
static char *derive_canonical_root(const char *input_path, const char *worktree_root,
const char *git_common_dir, const char *abs_common_dir) {
char *root = NULL;
if (abs_common_dir && abs_common_dir[0] && path_is_absolute(abs_common_dir)) {
root = git_strdup(abs_common_dir);
if (!root) {
return NULL;
}
} else {
const char *src = git_common_dir && git_common_dir[0] ? git_common_dir : worktree_root;
if (!src) {
return git_strdup("");
}
#ifndef _WIN32
root = path_is_absolute(src) ? git_strdup(src) : join_root_relative(input_path, src);
if (!root) {
return NULL;
}
{
char resolved[4096];
if (realpath(root, resolved) != NULL) {
free(root);
root = git_strdup(resolved);
if (!root) {
return NULL;
}
}
}
#else
(void)input_path;
root = path_is_absolute(src) ? git_strdup(src) : join_root_relative(worktree_root, src);
if (!root) {
return NULL;
}
#endif
}
size_t len = strlen(root);
while (len > 1 && (root[len - 1] == '/' || root[len - 1] == '\\')) {
root[--len] = '\0';
}
if (len >= 5 && strcmp(root + len - 5, "/.git") == 0) {
root[len - 5] = '\0';
}
#ifdef _WIN32
else if (len >= 5 && strcmp(root + len - 5, "\\.git") == 0) {
root[len - 5] = '\0';
}
#endif
return root;
}
static char *slug_from_branch(const char *branch, bool detached) {
const char *fallback = detached ? "detached" : "working-tree";
const char *src = detached ? fallback : (branch && branch[0] ? branch : fallback);
size_t len = strlen(src);
char *slug = (char *)malloc(len + 1);
if (!slug) {
return NULL;
}
size_t j = 0;
bool in_dash = false;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)src[i];
if (isalnum(c) || c == '-' || c == '_' || c == '.') {
if (j == 0 && c == '-') {
in_dash = true;
continue;
}
slug[j++] = (char)c;
in_dash = false;
} else if (j > 0 && !in_dash) {
slug[j++] = '-';
in_dash = true;
}
}
while (j > 0 && slug[j - 1] == '-') {
j--;
}
slug[j] = '\0';
if (slug[0] == '\0') {
free(slug);
return git_strdup(fallback);
}
return slug;
}
void cbm_git_context_free(cbm_git_context_t *ctx) {
if (!ctx) {
return;
}
free(ctx->input_path);
free(ctx->worktree_root);
free(ctx->git_dir);
free(ctx->git_common_dir);
free(ctx->canonical_root);
free(ctx->branch);
free(ctx->branch_slug);
free(ctx->head_sha);
free(ctx->base_sha);
memset(ctx, 0, sizeof(*ctx));
}
int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) {
if (!out) {
return CBM_NOT_FOUND;
}
memset(out, 0, sizeof(*out));
if (!path || !path[0]) {
return CBM_NOT_FOUND;
}
out->input_path = git_strdup(path);
if (!out->input_path) {
return CBM_NOT_FOUND;
}
struct stat st;
out->root_exists = (stat(path, &st) == 0);
if (!out->root_exists) {
return 0;
}
if (git_capture(path, "rev-parse --show-toplevel", &out->worktree_root) != 0) {
out->is_git = false;
return 0;
}
out->is_git = true;
if (git_capture(path, "rev-parse --git-dir", &out->git_dir) != 0) {
out->git_dir = git_strdup("");
}
if (git_capture(path, "rev-parse --git-common-dir", &out->git_common_dir) != 0) {
out->git_common_dir = git_strdup("");
}
if (git_capture(path, "rev-parse --verify HEAD", &out->head_sha) != 0) {
out->head_sha = git_strdup("");
}
if (git_capture(path, "symbolic-ref --quiet --short HEAD", &out->branch) != 0) {
out->branch = git_strdup("DETACHED");
out->is_detached = true;
}
out->is_worktree =
out->git_dir && out->git_common_dir && strcmp(out->git_dir, out->git_common_dir) != 0;
/* git 2.31+ canonical absolute common-dir (best-effort; NULL on older git,
* where derive_canonical_root falls back to the relative common-dir). */
char *abs_common_dir = NULL;
(void)git_capture(path, "rev-parse --path-format=absolute --git-common-dir", &abs_common_dir);
out->canonical_root =
derive_canonical_root(path, out->worktree_root, out->git_common_dir, abs_common_dir);
free(abs_common_dir);
out->branch_slug = slug_from_branch(out->branch, out->is_detached);
if (git_capture(path, "merge-base HEAD @{upstream}", &out->base_sha) != 0) {
out->base_sha = git_strdup("");
}
if (!out->git_dir || !out->git_common_dir || !out->head_sha || !out->branch ||
!out->canonical_root || !out->branch_slug || !out->base_sha) {
cbm_git_context_free(out);
return CBM_NOT_FOUND;
}
return 0;
}
char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx) {
const char *project = project_name && project_name[0] ? project_name : "project";
const char *slug = "working-tree";
if (ctx) {
if (ctx->is_detached) {
slug = "detached";
} else if (ctx->is_git && ctx->branch_slug && ctx->branch_slug[0]) {
slug = ctx->branch_slug;
}
}
int n = snprintf(NULL, 0, "%s.__branch__.%s", project, slug);
if (n < 0) {
return NULL;
}
char *out = (char *)malloc((size_t)n + 1);
if (!out) {
return NULL;
}
snprintf(out, (size_t)n + 1, "%s.__branch__.%s", project, slug);
return out;
}
static bool append_fmt_checked(char *buf, int buf_size, int *off, const char *fmt, ...) {
if (!buf || !off || buf_size <= 0 || *off < 0 || *off >= buf_size) {
return false;
}
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf + *off, (size_t)(buf_size - *off), fmt, ap);
va_end(ap);
if (n < 0 || n >= buf_size - *off) {
buf[buf_size - 1] = '\0';
return false;
}
*off += n;
return true;
}
static int json_escaped_len(const char *src) {
if (!src) {
return 0;
}
int len = 0;
for (int i = 0; src[i]; i++) {
unsigned char c = (unsigned char)src[i];
if (c == '"' || c == '\\' || c == '\n' || c == '\r' || c == '\t') {
len += 2;
} else if (c < 0x20) {
len += 6; /* \u00XX */
} else {
len++;
}
}
return len;
}
static bool json_append_bool(char *buf, int buf_size, int *off, const char *name, bool value,
bool comma) {
return append_fmt_checked(buf, buf_size, off, "\"%s\":%s%s", name, value ? "true" : "false",
comma ? "," : "");
}
static bool json_append_string(char *buf, int buf_size, int *off, const char *name,
const char *value, bool comma) {
int needed = json_escaped_len(value ? value : "");
char *escaped = malloc((size_t)needed + 1);
if (!escaped) {
return false;
}
int actual = cbm_json_escape(escaped, needed + 1, value ? value : "");
bool ok = actual == needed && append_fmt_checked(buf, buf_size, off, "\"%s\":\"%s\"%s", name,
escaped, comma ? "," : "");
free(escaped);
return ok;
}
int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size) {
if (!ctx || !buf || buf_size <= 0) {
return 0;
}
int off = 0;
bool ok =
append_fmt_checked(buf, buf_size, &off, "{") &&
json_append_bool(buf, buf_size, &off, "is_git", ctx->is_git, true) &&
json_append_bool(buf, buf_size, &off, "is_worktree", ctx->is_worktree, true) &&
json_append_bool(buf, buf_size, &off, "is_detached", ctx->is_detached, true) &&
json_append_bool(buf, buf_size, &off, "root_exists", ctx->root_exists, true) &&
json_append_string(buf, buf_size, &off, "canonical_root", ctx->canonical_root, true) &&
json_append_string(buf, buf_size, &off, "worktree_root", ctx->worktree_root, true) &&
json_append_string(buf, buf_size, &off, "git_common_dir", ctx->git_common_dir, true) &&
json_append_string(buf, buf_size, &off, "branch", ctx->branch, true) &&
json_append_string(buf, buf_size, &off, "head_sha", ctx->head_sha, true) &&
json_append_string(buf, buf_size, &off, "base_sha", ctx->base_sha, false) &&
append_fmt_checked(buf, buf_size, &off, "}");
if (!ok) {
if (buf_size > 0) {
buf[0] = '\0';
}
return 0;
}
return off;
}
+27
View File
@@ -0,0 +1,27 @@
#ifndef CBM_GIT_CONTEXT_H
#define CBM_GIT_CONTEXT_H
#include <stdbool.h>
typedef struct {
bool is_git;
bool is_worktree;
bool is_detached;
bool root_exists;
char *input_path;
char *worktree_root;
char *git_dir;
char *git_common_dir;
char *canonical_root;
char *branch;
char *branch_slug;
char *head_sha;
char *base_sha;
} cbm_git_context_t;
int cbm_git_context_resolve(const char *path, cbm_git_context_t *out);
void cbm_git_context_free(cbm_git_context_t *ctx);
char *cbm_git_context_branch_qn(const char *project_name, const cbm_git_context_t *ctx);
int cbm_git_context_props_json(const cbm_git_context_t *ctx, char *buf, int buf_size);
#endif
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
/*
* graph_buffer.h — In-memory graph buffer for pipeline indexing.
*
* Holds all nodes and edges in RAM during indexing, then dumps to SQLite.
* Provides O(1) node lookup by qualified name and edge dedup by key.
*
* Depends on: foundation (hash_table, dyn_array), store (data structs)
*/
#ifndef CBM_GRAPH_BUFFER_H
#define CBM_GRAPH_BUFFER_H
#include <stdbool.h>
#include <stdint.h>
#include <stdatomic.h>
/* ── Opaque handle ──────────────────────────────────────────────── */
typedef struct cbm_gbuf cbm_gbuf_t;
/* Forward declare store for dump path */
typedef struct cbm_store cbm_store_t;
/* ── Node / Edge structs (owned by the buffer) ───────────────────── */
typedef struct {
int64_t id; /* temp ID (sequential from 1) */
char *label; /* heap-owned */
char *name; /* heap-owned */
char *qualified_name; /* heap-owned */
char *file_path; /* heap-owned */
int start_line;
int end_line;
char *properties_json; /* heap-owned JSON string, "{}" default */
} cbm_gbuf_node_t;
typedef struct {
int64_t id; /* temp ID */
int64_t source_id; /* temp node ID */
int64_t target_id; /* temp node ID */
char *type; /* heap-owned */
char *properties_json; /* heap-owned JSON string, "{}" default */
} cbm_gbuf_edge_t;
/* ── Lifecycle ──────────────────────────────────────────────────── */
/* Create a new graph buffer for a project. */
cbm_gbuf_t *cbm_gbuf_new(const char *project, const char *root_path);
/* Create a graph buffer with a shared atomic ID source.
* IDs are allocated via atomic_fetch_add on *id_source.
* Used for parallel extraction where multiple gbufs need unique IDs.
* If id_source is NULL, behaves like cbm_gbuf_new(). */
cbm_gbuf_t *cbm_gbuf_new_shared_ids(const char *project, const char *root_path,
_Atomic int64_t *id_source);
/* Free the graph buffer and all owned data. NULL-safe. */
void cbm_gbuf_free(cbm_gbuf_t *gb);
/* Merge all nodes and edges from src into dst.
* Nodes are merged by QN: on collision, src wins (updates dst node fields).
* New nodes are inserted with their original IDs (from shared ID source).
* Edges are remapped for any QN-colliding nodes, then inserted with dedup.
* After merge, src can be safely freed (all data is copied).
* Returns 0 on success, -1 on error. */
int cbm_gbuf_merge(cbm_gbuf_t *dst, cbm_gbuf_t *src);
/* ── Node operations ─────────────────────────────────────────────── */
/* Upsert a node by qualified name. Returns the temp ID.
* All string fields are copied (buffer owns the copies).
* Returns 0 on error. */
int64_t cbm_gbuf_upsert_node(cbm_gbuf_t *gb, const char *label, const char *name,
const char *qualified_name, const char *file_path, int start_line,
int end_line, const char *properties_json);
/* Find a node by qualified name. Returns NULL if not found. */
const cbm_gbuf_node_t *cbm_gbuf_find_by_qn(const cbm_gbuf_t *gb, const char *qn);
/* Find a node by temp ID. Returns NULL if not found. */
const cbm_gbuf_node_t *cbm_gbuf_find_by_id(const cbm_gbuf_t *gb, int64_t id);
/* Find nodes by label. Sets *out and *count. Caller does NOT free.
* Returns 0 on success, -1 on error. */
int cbm_gbuf_find_by_label(const cbm_gbuf_t *gb, const char *label, const cbm_gbuf_node_t ***out,
int *count);
/* Find nodes by name (exact). Sets *out and *count. Caller does NOT free. */
int cbm_gbuf_find_by_name(const cbm_gbuf_t *gb, const char *name, const cbm_gbuf_node_t ***out,
int *count);
/* Count total nodes in buffer. */
int cbm_gbuf_node_count(const cbm_gbuf_t *gb);
/* Get the next ID that would be assigned. Used to initialize shared atomic counters. */
int64_t cbm_gbuf_next_id(const cbm_gbuf_t *gb);
/* Set the next ID counter. Used after merging worker gbufs to sync the main counter. */
void cbm_gbuf_set_next_id(cbm_gbuf_t *gb, int64_t next_id);
/* Delete all nodes with a label. Cascade-deletes referencing edges. */
int cbm_gbuf_delete_by_label(cbm_gbuf_t *gb, const char *label);
/* Delete all nodes for a given file path. Cascade-deletes referencing edges.
* Used by incremental indexing to remove stale nodes before re-extraction. */
int cbm_gbuf_delete_by_file(cbm_gbuf_t *gb, const char *file_path);
/* Bulk-load all nodes and edges for a project from an existing SQLite DB
* into this graph buffer. Returns 0 on success. */
int cbm_gbuf_load_from_db(cbm_gbuf_t *gb, const char *db_path, const char *project);
/* Iterate all live nodes (not deleted from QN index). */
typedef void (*cbm_gbuf_node_visitor_fn)(const cbm_gbuf_node_t *node, void *userdata);
void cbm_gbuf_foreach_node(const cbm_gbuf_t *gb, cbm_gbuf_node_visitor_fn fn, void *userdata);
/* Iterate all edges. */
typedef void (*cbm_gbuf_edge_visitor_fn)(const cbm_gbuf_edge_t *edge, void *userdata);
void cbm_gbuf_foreach_edge(const cbm_gbuf_t *gb, cbm_gbuf_edge_visitor_fn fn, void *userdata);
/* ── Edge operations ─────────────────────────────────────────────── */
/* Insert an edge. Deduplicates by (source_id, target_id, type).
* On duplicate, merges properties (later wins). Returns edge temp ID.
* Returns 0 on error. */
int64_t cbm_gbuf_insert_edge(cbm_gbuf_t *gb, int64_t source_id, int64_t target_id, const char *type,
const char *properties_json);
/* Find edges from source_id with given type.
* Sets *out and *count. Caller does NOT free. */
int cbm_gbuf_find_edges_by_source_type(const cbm_gbuf_t *gb, int64_t source_id, const char *type,
const cbm_gbuf_edge_t ***out, int *count);
/* Find edges to target_id with given type. */
int cbm_gbuf_find_edges_by_target_type(const cbm_gbuf_t *gb, int64_t target_id, const char *type,
const cbm_gbuf_edge_t ***out, int *count);
/* Find all edges of a given type. */
int cbm_gbuf_find_edges_by_type(const cbm_gbuf_t *gb, const char *type,
const cbm_gbuf_edge_t ***out, int *count);
/* Count total edges. */
int cbm_gbuf_edge_count(const cbm_gbuf_t *gb);
/* Count edges of a given type. */
int cbm_gbuf_edge_count_by_type(const cbm_gbuf_t *gb, const char *type);
/* Delete all edges of a type. */
int cbm_gbuf_delete_edges_by_type(cbm_gbuf_t *gb, const char *type);
/* ── Vector storage (for semantic embeddings) ───────────────────── */
/* Store an int8-quantized vector for a node. The vector data is copied.
* Called by pass_semantic_edges after computing RI vectors.
* Vectors are carried through to cbm_write_db during the dump phase. */
int cbm_gbuf_store_vector(cbm_gbuf_t *gb, int64_t node_id, const uint8_t *vector, int vector_len);
/* Store an enriched token vector for query-time lookup.
* Called by pass_semantic_edges after corpus finalization.
* Token string and vector data are copied. */
int cbm_gbuf_store_token_vector(cbm_gbuf_t *gb, const char *token, const uint8_t *vector,
int vector_len, float idf);
/* ── Dump to SQLite ──────────────────────────────────────────────── */
/* Dump the entire buffer to a SQLite file using the direct page writer.
* Assigns sequential final IDs and remaps edge references.
* Returns 0 on success, -1 on error. */
int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path);
/* Flush the buffer to an existing store via the store API.
* Deletes existing project data first. Returns 0 on success. */
int cbm_gbuf_flush_to_store(cbm_gbuf_t *gb, cbm_store_t *store);
/* Merge the buffer into an existing store WITHOUT deleting existing data.
* Upserts nodes, inserts edges. Used for incremental indexing.
* Returns 0 on success. */
int cbm_gbuf_merge_into_store(cbm_gbuf_t *gb, cbm_store_t *store);
#endif /* CBM_GRAPH_BUFFER_H */
+837
View File
@@ -0,0 +1,837 @@
/*
* main.c — Entry point for codebase-memory-mcp.
*
* Modes:
* (default) Run as MCP server on stdin/stdout (JSON-RPC 2.0)
* cli <tool> <json> Run a single tool call and print result
* --version Print version and exit
* --help Print usage and exit
* --ui=true/false Enable/disable HTTP UI server (persisted)
* --port=N Set HTTP UI port (persisted, default 9749)
*
* Signal handling: SIGTERM/SIGINT trigger graceful shutdown.
* Watcher runs in a background thread, polling for git changes.
* HTTP UI server (optional) runs in a background thread on localhost.
*/
#include "cbm.h" // cbm_alloc_init — bind 3rd-party allocators to mimalloc before any sqlite/git init
#include "mcp/mcp.h"
#include "mcp/index_supervisor.h"
#include "watcher/watcher.h"
#include "pipeline/pipeline.h"
#include "store/store.h"
#include "cli/cli.h"
#include "cli/progress_sink.h"
#include "foundation/constants.h"
enum {
MAIN_MIN_ARGC = 1,
MAIN_CLI_ARGC = 2,
MAIN_FLAG_OFF = 5, /* strlen("--ui=") */
MAIN_PORT_OFF = 7, /* strlen("--port=") */
MAIN_MAX_PORT = 65536,
PARENT_WATCHDOG_STACK_SIZE = 64 * CBM_SZ_1K, /* watchdog only polls — tiny stack suffices */
};
#define SLEN(s) (sizeof(s) - 1)
#include "foundation/log.h"
#include "foundation/diagnostics.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/compat_thread.h"
#include "foundation/mem.h"
#include "foundation/profile.h"
#include "foundation/win_utf8.h" /* cbm_wide_to_utf8 — Windows UTF-8 argv (#423/#20); no-op on POSIX */
#ifdef _WIN32
#include <shellapi.h> /* CommandLineToArgvW — not pulled in by windows.h under WIN32_LEAN_AND_MEAN */
#endif
#include "ui/config.h"
#include "ui/http_server.h"
#include "ui/embedded_assets.h"
#include <yyjson/yyjson.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <stdatomic.h>
#ifndef _WIN32
#include <unistd.h>
#endif
#ifndef CBM_VERSION
#define CBM_VERSION "dev"
#endif
/* ── Globals for signal handling ────────────────────────────────── */
static cbm_watcher_t *g_watcher = NULL;
static cbm_mcp_server_t *g_server = NULL;
static cbm_http_server_t *g_http_server = NULL;
static atomic_int g_shutdown = 0;
/* Idempotent shutdown: cancels the active pipeline, stops background servers,
* and closes stdin to unblock the MCP read loop. Invoked from the signal
* handler and from the parent-death watchdog, hence the atomic_exchange guard
* so the body runs at most once. Body is async-signal-safe (only atomic stores
* and stop calls that themselves only set atomics). */
static void request_shutdown(void) {
if (atomic_exchange(&g_shutdown, 1)) {
return; /* already shutting down */
}
/* Cancel any in-progress pipeline (async-signal-safe: only does atomic_store) */
if (g_server) {
cbm_pipeline_t *p = cbm_mcp_server_active_pipeline(g_server);
if (p) {
cbm_pipeline_cancel(p);
}
}
/* Release pipeline lock to prevent stale lock on restart */
cbm_pipeline_unlock();
if (g_watcher) {
cbm_watcher_stop(g_watcher);
}
if (g_http_server) {
cbm_http_server_stop(g_http_server);
}
/* Close stdin to unblock getline in the MCP server loop */
(void)fclose(stdin);
}
static void signal_handler(int sig) {
(void)sig;
request_shutdown();
}
/* ── Parent-process watchdog ────────────────────────────────────── */
/* parent-death watchdog — distilled from #407 (fixes #406, thanks @nvt-pankajsharma).
*
* When this stdio MCP server is launched by an agent that later dies without a
* clean SIGTERM (e.g. the editor is force-killed), the orphaned server would
* otherwise linger forever blocked on stdin. POSIX has no portable "notify on
* parent death" primitive (PR_SET_PDEATHSIG is Linux-only), so we poll getppid:
* once the parent dies the process is reparented (ppid changes, typically to 1)
* and we shut down. Windows is unaffected (job objects handle this) — #ifndef. */
#ifndef _WIN32
static void *parent_watchdog_thread(void *arg) {
pid_t initial_ppid = *(pid_t *)arg;
const unsigned int poll_interval_us = 500000; /* 500ms */
while (!atomic_load(&g_shutdown)) {
cbm_usleep(poll_interval_us);
if (atomic_load(&g_shutdown)) {
break;
}
/* initial_ppid > 1 guards against an already-orphaned start (ppid==1),
* where a changing ppid carries no signal. */
if (initial_ppid > 1 && getppid() != initial_ppid) {
static const char msg[] = "level=warn msg=parent.exited reason=ppid_changed\n";
(void)write(STDERR_FILENO, msg, sizeof(msg) - 1);
_exit(0);
}
}
return NULL;
}
#endif
/* ── Watcher background thread ──────────────────────────────────── */
static void *watcher_thread(void *arg) {
cbm_watcher_t *w = arg;
#define WATCHER_BASE_INTERVAL_MS 5000
cbm_watcher_run(w, WATCHER_BASE_INTERVAL_MS);
return NULL;
}
/* ── HTTP UI background thread ──────────────────────────────────── */
static void *http_thread(void *arg) {
cbm_http_server_t *srv = arg;
cbm_http_server_run(srv);
return NULL;
}
/* ── Index callback for watcher ─────────────────────────────────── */
static int watcher_index_fn(const char *project_name, const char *root_path, void *user_data) {
(void)user_data;
/* Skip indexing if shutdown is in progress (skipped, not indexed) */
if (atomic_load(&g_shutdown)) {
return 1;
}
/* Non-blocking: skip if another pipeline is already running. The
* positive return keeps the watcher's baselines uncommitted so the
* change is retried on the next poll cycle (5-60s) instead of being
* recorded as seen and lost (#937). */
if (!cbm_pipeline_try_lock()) {
cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy");
return 1;
}
cbm_log_info("watcher.reindex", "project", project_name, "path", root_path);
/* #832: route the re-index through the supervised worker subprocess so this
* long-lived server process hands its RSS back to the OS on every cycle
* instead of ratcheting (mimalloc v3 does not reclaim pages that worker
* threads abandon at exit). The child writes the DB; the parent only needs the
* return code. The pipeline lock (already held) still serialises re-indexes.
* Degrade to the in-process pipeline when the supervisor is off (kill switch)
* or the spawn fails. */
if (cbm_index_supervisor_should_wrap()) {
char *resp = cbm_mcp_index_run_supervised_path(root_path);
if (resp) {
free(resp);
cbm_pipeline_unlock();
return 0;
}
/* resp == NULL → spawn-failure degrade → fall through to in-process. */
}
cbm_pipeline_t *p = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL);
if (!p) {
cbm_pipeline_unlock();
return CBM_NOT_FOUND;
}
int rc = cbm_pipeline_run(p);
cbm_pipeline_free(p);
cbm_pipeline_unlock();
return rc;
}
/* ── CLI mode ───────────────────────────────────────────────────── */
#define CLI_USAGE "Usage: codebase-memory-mcp cli [--progress] [--json] <tool_name> [json_args]\n"
/* Extract text content from MCP tool result envelope and print it.
* MCP results: {"content":[{"type":"text","text":"..."}],"isError":...}
* Returns 1 if the result was an error, 0 otherwise. */
static int cli_print_mcp_result(const char *result) {
yyjson_doc *doc = yyjson_read(result, strlen(result), 0);
if (!doc) {
printf("%s\n", result);
return 0;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *err_val = yyjson_obj_get(root, "isError");
bool is_error = err_val && yyjson_get_bool(err_val);
const char *text = NULL;
yyjson_val *content = yyjson_obj_get(root, "content");
if (yyjson_is_arr(content) && yyjson_arr_size(content) > 0) {
yyjson_val *tv = yyjson_obj_get(yyjson_arr_get_first(content), "text");
text = tv ? yyjson_get_str(tv) : NULL;
}
if (text) {
(void)fprintf(is_error ? stderr : stdout, "%s\n", text);
} else {
printf("%s\n", result);
}
yyjson_doc_free(doc);
return is_error ? SKIP_ONE : 0;
}
/* Strip a flag from argv, returning true if found. */
static bool cli_strip_flag(int *argc, char **argv, const char *flag) {
for (int i = 0; i < *argc; i++) {
if (strcmp(argv[i], flag) != 0) {
continue;
}
for (int j = i; j < *argc - SKIP_ONE; j++) {
argv[j] = argv[j + SKIP_ONE];
}
(*argc)--;
return true;
}
return false;
}
/* Strip a flag AND its following value from argv, returning the value (a pointer
* into the original argv strings, valid for the process lifetime) or NULL if the
* flag is absent. */
static const char *cli_strip_flag_value(int *argc, char **argv, const char *flag) {
for (int i = 0; i < *argc; i++) {
if (strcmp(argv[i], flag) != 0) {
continue;
}
const char *value = (i + SKIP_ONE < *argc) ? argv[i + SKIP_ONE] : NULL;
int remove_count = value ? 2 : 1;
for (int j = i; j < *argc - remove_count; j++) {
argv[j] = argv[j + remove_count];
}
*argc -= remove_count;
return value;
}
return NULL;
}
/* Portable "is fd a terminal?" — _isatty on Windows, isatty on POSIX. */
#ifdef _WIN32
#define cli_isatty(fd) _isatty(fd)
#else
#define cli_isatty(fd) isatty(fd)
#endif
enum { CLI_SLURP_CHUNK = 4096 };
/* Read an open stream fully into a heap, NUL-terminated string. Caller frees.
* Returns NULL on allocation failure. Reads binary-clean (UTF-8 JSON, no shell
* quoting needed). */
static char *cli_slurp_stream(FILE *f) {
size_t cap = CLI_SLURP_CHUNK;
size_t len = 0;
char *buf = malloc(cap);
if (!buf) {
return NULL;
}
char tmp[CLI_SLURP_CHUNK];
size_t n;
while ((n = fread(tmp, 1, sizeof(tmp), f)) > 0) {
if (len + n + 1 > cap) {
while (len + n + 1 > cap) {
cap *= 2;
}
char *nb = realloc(buf, cap);
if (!nb) {
free(buf);
return NULL;
}
buf = nb;
}
memcpy(buf + len, tmp, n);
len += n;
}
buf[len] = '\0';
return buf;
}
/* Slurp a file path into a heap, NUL-terminated string. Caller frees. */
static char *cli_slurp_file(const char *path) {
FILE *f = fopen(path, "rb");
if (!f) {
return NULL;
}
char *s = cli_slurp_stream(f);
(void)fclose(f);
return s;
}
/* True if the first non-whitespace byte of s is '{' (raw-JSON detection). */
static bool cli_first_nonspace_is_brace(const char *s) {
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') {
s++;
}
return *s == '{';
}
static int run_cli(int argc, char **argv) {
if (argc < MAIN_MIN_ARGC) {
(void)fprintf(stderr, CLI_USAGE);
return SKIP_ONE;
}
bool progress = cli_strip_flag(&argc, argv, "--progress");
bool raw_json = cli_strip_flag(&argc, argv, "--json");
/* Supervisor worker role: when this process was spawned as a supervised index
* worker, run indexing in-process (never re-supervise) and write the result to
* the given file for the parent to read back. Stripped here so the tool
* dispatch below sees only the tool name + its args. */
bool index_worker = cli_strip_flag(&argc, argv, "--index-worker");
const char *response_out = cli_strip_flag_value(&argc, argv, "--response-out");
cbm_index_set_worker_role(index_worker, response_out);
#ifndef _WIN32
/* #845: a supervised worker must not outlive its supervisor. If the parent
* dies without reaping us (agent killed, supervisor crashed), an orphaned
* worker would index on unsupervised — observed contributing to memory
* pressure during the 2026-07-04 host panics. Reuse the parent-death
* watchdog (safe outside server mode: on ppid change it only writes to
* stderr and _exit(0)s — no cleanup dependencies). Detached: the worker
* exits by returning from run_cli; exit() tears the thread down. Failure
* to start is non-fatal, same policy as the MCP-server watchdog. */
if (index_worker) {
static pid_t worker_initial_ppid; /* static: outlives run_cli for the thread */
worker_initial_ppid = getppid();
cbm_thread_t worker_watchdog_tid;
if (cbm_thread_create(&worker_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE,
parent_watchdog_thread, &worker_initial_ppid) == 0) {
(void)cbm_thread_detach(&worker_watchdog_tid);
cbm_log_info("worker.watchdog.start");
} else {
cbm_log_warn("worker.watchdog.unavailable", "reason", "thread_create_failed");
}
}
#endif
if (argc < MAIN_MIN_ARGC) {
(void)fprintf(stderr, CLI_USAGE);
return SKIP_ONE;
}
const char *tool_name = argv[0];
int rem_argc = argc - SKIP_ONE; /* args following the tool name */
char **rem_argv = argv + SKIP_ONE;
/* --help / -h : print per-tool help (from the tool's input_schema) and exit
* before any server work. */
for (int i = 0; i < rem_argc; i++) {
if (strcmp(rem_argv[i], "--help") == 0 || strcmp(rem_argv[i], "-h") == 0) {
if (cbm_cli_print_tool_help(tool_name) != 0) {
(void)fprintf(stderr, "error: unknown tool '%s'\n", tool_name);
return SKIP_ONE;
}
return 0;
}
}
/* Resolve the JSON arguments. Precedence: --args-file, then raw JSON
* (back-compat), then --flags, then piped stdin, then empty {}. */
char *heap_args = NULL; /* freed before return when set */
const char *args_json = "{}";
int args_file_idx = -1;
for (int i = 0; i < rem_argc; i++) {
if (strcmp(rem_argv[i], "--args-file") == 0) {
args_file_idx = i;
break;
}
}
if (args_file_idx >= 0) {
if (args_file_idx + SKIP_ONE >= rem_argc) {
(void)fprintf(stderr, "error: --args-file requires a path argument\n");
return SKIP_ONE;
}
const char *path = rem_argv[args_file_idx + SKIP_ONE];
heap_args = cli_slurp_file(path);
if (!heap_args) {
(void)fprintf(stderr, "error: cannot read args file '%s'\n", path);
return SKIP_ONE;
}
args_json = heap_args;
} else if (rem_argc >= SKIP_ONE && cli_first_nonspace_is_brace(rem_argv[0])) {
/* raw-JSON back-compat: cli <tool> '{"k":"v"}' (deprecated path). Warn on
* STDERR only — stdout must stay clean JSON for piping. */
(void)fprintf(stderr,
"warning: passing raw JSON to 'cli %s' is deprecated and "
"will be removed in a future release; use flags (run 'cli "
"%s --help'), --args-file <path>, or piped stdin.\n",
tool_name, tool_name);
args_json = rem_argv[0];
} else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) {
/* flag form: cli <tool> --flag value --bare-bool ... */
char *err = NULL;
heap_args = cbm_cli_build_args_json(tool_name, rem_argc, rem_argv, &err);
if (!heap_args) {
(void)fprintf(stderr, "error: %s\n", err ? err : "invalid arguments");
free(err);
return SKIP_ONE;
}
args_json = heap_args;
} else if (!cli_isatty(0)) {
/* piped stdin (UTF-8 clean, no shell quoting): cli <tool> < args.json */
heap_args = cli_slurp_stream(stdin);
if (heap_args && heap_args[0]) {
args_json = heap_args;
} else {
free(heap_args);
heap_args = NULL;
args_json = "{}";
}
}
if (progress) {
cbm_progress_sink_init(stderr);
}
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
(void)fprintf(stderr, "error: failed to create server\n");
if (progress) {
cbm_progress_sink_fini();
}
return SKIP_ONE;
}
char *result = cbm_mcp_handle_tool(srv, tool_name, args_json);
int exit_code = 0;
if (result) {
/* Supervised worker: hand the full result string to the parent via the
* response file before printing (parent reads it back on a clean exit). */
const char *ro = cbm_index_worker_response_out();
if (ro) {
FILE *rf = cbm_fopen(ro, "wb");
if (rf) {
(void)fputs(result, rf);
(void)fclose(rf);
}
}
if (raw_json) {
printf("%s\n", result);
} else {
exit_code = cli_print_mcp_result(result);
}
if (cbm_index_worker_active()) {
/* Supervised worker: the response is delivered (file + stdout).
* Skip the multi-GB teardown (server/store frees) — the process
* dies now and the OS reclaims everything wholesale; piecemeal
* free() of a kernel-scale graph costs minutes. _Exit skips
* atexit/LSan by design for this prod worker path. */
cbm_log_info("index.worker.fast_exit", "action", "_Exit");
fflush(NULL);
_Exit(exit_code);
}
free(result);
}
cbm_mcp_server_free(srv);
if (progress) {
cbm_progress_sink_fini();
}
free(heap_args);
return exit_code;
}
/* ── Help ───────────────────────────────────────────────────────── */
static void print_help(void) {
printf("codebase-memory-mcp %s\n\n", CBM_VERSION);
printf("Usage:\n");
printf(" codebase-memory-mcp Run MCP server on stdio\n");
printf(" codebase-memory-mcp cli <tool> [json] Run a single tool\n");
printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run]\n");
printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n");
printf(" codebase-memory-mcp update [-y|-n]\n");
printf(" codebase-memory-mcp config <list|get|set|reset>\n");
printf(" codebase-memory-mcp --version Print version\n");
printf(" codebase-memory-mcp --help Print this help\n");
printf("\nUI options:\n");
printf(" --ui=true Enable HTTP graph visualization (persisted)\n");
printf(" --ui=false Disable HTTP graph visualization (persisted)\n");
printf(" --port=N Set UI port (default 9749, persisted)\n");
printf("\nSupported agents (auto-detected):\n");
printf(" Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode,\n");
printf(" Antigravity, Aider, KiloCode, Kiro\n");
printf("\nTools: index_repository, search_graph, query_graph, trace_path,\n");
printf(" get_code_snippet, get_graph_schema, get_architecture, search_code,\n");
printf(" list_projects, delete_project, index_status, detect_changes,\n");
printf(" manage_adr, ingest_traces\n");
}
/* ── Main ───────────────────────────────────────────────────────── */
/* Try to handle a subcommand (cli/install/uninstall/update/config/--version/--help).
* Returns -1 if no subcommand matched, otherwise the exit code. */
static int handle_subcommand(int argc, char **argv) {
/* First scan: global flags */
for (int i = SKIP_ONE; i < argc; i++) {
if (strcmp(argv[i], "--profile") == 0) {
cbm_profile_enable();
}
}
for (int i = SKIP_ONE; i < argc; i++) {
if (strcmp(argv[i], "--version") == 0) {
printf("codebase-memory-mcp %s\n", CBM_VERSION);
return 0;
}
if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
print_help();
return 0;
}
if (strcmp(argv[i], "cli") == 0) {
cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram));
return run_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
}
if (strcmp(argv[i], "hook-augment") == 0) {
cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram));
return cbm_cmd_hook_augment();
}
if (strcmp(argv[i], "install") == 0) {
return cbm_cmd_install(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
}
if (strcmp(argv[i], "uninstall") == 0) {
return cbm_cmd_uninstall(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
}
if (strcmp(argv[i], "update") == 0) {
return cbm_cmd_update(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
}
if (strcmp(argv[i], "config") == 0) {
return cbm_cmd_config(argc - i - SKIP_ONE, argv + i + SKIP_ONE);
}
}
return CBM_NOT_FOUND;
}
/* Parse --ui= and --port= flags. Returns true if config was modified. */
static bool parse_ui_flags(int argc, char **argv, cbm_ui_config_t *cfg, bool *explicit_enable) {
bool changed = false;
for (int i = SKIP_ONE; i < argc; i++) {
if (strncmp(argv[i], "--ui=", SLEN("--ui=")) == 0) {
cfg->ui_enabled = (strcmp(argv[i] + MAIN_FLAG_OFF, "true") == 0);
if (explicit_enable && cfg->ui_enabled) {
*explicit_enable = true;
}
changed = true;
}
if (strncmp(argv[i], "--port=", SLEN("--port=")) == 0) {
int p = (int)strtol(argv[i] + MAIN_PORT_OFF, NULL, CBM_DECIMAL_BASE);
if (p > 0 && p < MAIN_MAX_PORT) {
cfg->ui_port = p;
changed = true;
}
}
}
return changed;
}
/* Install platform-specific signal handlers. */
static void setup_signal_handlers(void) {
#ifdef _WIN32
signal(SIGTERM, signal_handler);
signal(SIGINT, signal_handler);
#else
struct sigaction sa = {0};
sa.sa_handler = signal_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
#endif
}
#ifdef _WIN32
/* On Windows the CRT hands main() an argv encoded in the active ANSI code page, so a
* non-ASCII CLI argument (e.g. a repo path like café_日本語_repo) is mangled before the
* program ever sees it — the documented `cli index_repository "<json>"` then fails with
* "repo_path is required" (#423/#20). Rebuild argv from the wide command line
* (GetCommandLineW → CommandLineToArgvW) and convert each element to UTF-8 so the rest
* of the program receives the same UTF-8 bytes it gets on POSIX. Returns a
* NULL-terminated argv and sets *out_argc, or NULL on any failure (caller then keeps
* the original narrow argv). The returned block lives for the whole process (argv must
* stay valid until exit), so it is intentionally never freed. */
static char **cbm_win_utf8_argv(int *out_argc) {
int wargc = 0;
LPWSTR *wargv = CommandLineToArgvW(GetCommandLineW(), &wargc);
if (!wargv) {
return NULL;
}
if (wargc <= 0) {
LocalFree(wargv);
return NULL;
}
char **u8argv = (char **)calloc((size_t)wargc + 1, sizeof(char *));
if (!u8argv) {
LocalFree(wargv);
return NULL;
}
for (int i = 0; i < wargc; i++) {
u8argv[i] = cbm_wide_to_utf8(wargv[i]);
if (!u8argv[i]) {
for (int j = 0; j < i; j++) {
free(u8argv[j]);
}
free(u8argv);
LocalFree(wargv);
return NULL;
}
}
LocalFree(wargv);
*out_argc = wargc;
return u8argv; /* NULL-terminated (calloc'd wargc+1) */
}
#endif /* _WIN32 */
int main(int argc, char **argv) {
/* Defense-in-depth: bind tree-sitter and sqlite3 to mimalloc so a
* correct binary does not rely on the fragile MI_OVERRIDE symbol override
* (#424). MUST be the VERY FIRST statement: SQLITE_CONFIG_MALLOC has to run
* before the first sqlite3_open* (cbm_mcp_server_new → cbm_store_open_memory
* below opens sqlite early), else sqlite3_config returns SQLITE_MISUSE and
* the bind is silently ignored. No-op in the test build. */
cbm_alloc_init();
#ifdef _WIN32
/* Replace the ANSI-code-page argv the CRT handed us with a UTF-8 argv rebuilt from
* the wide command line, so non-ASCII CLI arguments survive (#423/#20). Falls back
* to the original argv if the wide rebuild fails. Done after cbm_alloc_init (which
* must stay the very first statement) but before argv is first read below. */
{
int win_argc = 0;
char **win_argv = cbm_win_utf8_argv(&win_argc);
if (win_argv) {
argc = win_argc;
argv = win_argv;
}
}
#endif
/* #845: mark this process as the REAL binary so the index supervisor may
* wrap index_repository in a worker subprocess. Must run before any
* subcommand dispatch so MCP-server, CLI, and HTTP paths are all covered.
* Embedders of cbm_mcp_handle_tool (test binaries) never mark themselves,
* so they index in-process instead of re-invoking themselves as
* `<self> cli --index-worker …` (recursive suite re-runs / spawn chains). */
cbm_index_supervisor_mark_host();
cbm_cli_set_version(CBM_VERSION);
cbm_profile_init(); /* reads CBM_PROFILE env var, gates all prof macros */
/* CBM_LOG_LEVEL support — distilled from #414 (closes #413). Apply before
* the first log statement so the configured level governs all output. */
cbm_log_init_from_env();
int subcmd = handle_subcommand(argc, argv);
if (subcmd >= 0) {
return subcmd;
}
/* parent-death watchdog — distilled from #407 (fixes #406). Start it early so
* an orphaned server exits even if it dies before reaching the MCP loop. A
* thread-create failure (or ppid<=1) is non-fatal: the server still runs, it
* just won't auto-exit on parent death — same policy as the watcher/HTTP
* threads below. We deliberately do NOT exit at startup when ppid<=1 (the PR's
* original behaviour): a legitimately-launched server can transiently show
* ppid==1 (early reparent races, double-fork/container launchers), and the
* watchdog already no-ops safely in that case via its initial_ppid>1 guard. */
#ifndef _WIN32
/* main() outlives the watchdog (it joins before returning), so a stack
* local is a valid lifetime for the thread's argument. */
pid_t initial_ppid = getppid();
cbm_thread_t parent_watchdog_tid;
bool parent_watchdog_started = false;
if (cbm_thread_create(&parent_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread,
&initial_ppid) == 0) {
parent_watchdog_started = true;
} else {
cbm_log_warn("parent.watchdog.unavailable", "reason", "thread_create_failed");
}
#endif
/* Default: MCP server on stdio */
cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram));
/* Store binary path for subprocess spawning + hook log sink */
cbm_http_server_set_binary_path(argv[0]);
cbm_log_set_sink_ex(cbm_ui_log_append, CBM_LOG_SINK_TEE);
cbm_log_info("server.start", "version", CBM_VERSION);
cbm_diag_start(); /* starts if CBM_DIAGNOSTICS=1 */
/* Parse --ui and --port flags (persisted config) */
cbm_ui_config_t ui_cfg;
cbm_ui_config_load(&ui_cfg);
bool explicit_ui_enable = false;
if (parse_ui_flags(argc, argv, &ui_cfg, &explicit_ui_enable)) {
cbm_ui_config_save(&ui_cfg);
}
/* If the user explicitly asked for the UI but this binary has no embedded
* frontend, the HTTP server can never start (see below). The warning that
* covers this goes to the log sink, which a user running `--ui=true` on a
* terminal won't see — so tell them plainly on stderr why nothing happens
* and which build to use (#350). */
if (explicit_ui_enable && CBM_EMBEDDED_FILE_COUNT == 0) {
(void)fprintf(stderr,
"codebase-memory-mcp: --ui requested, but this binary was built without the "
"embedded UI, so the HTTP server will not start.\n"
"Use the UI release asset (codebase-memory-mcp-ui) or rebuild with: "
"make -f Makefile.cbm cbm-with-ui\n");
}
setup_signal_handlers();
/* Open config store for runtime settings */
char config_dir[CBM_SZ_1K];
const char *cfg_home = cbm_get_home_dir();
cbm_config_t *runtime_config = NULL;
if (cfg_home) {
snprintf(config_dir, sizeof(config_dir), "%s", cbm_resolve_cache_dir());
runtime_config = cbm_config_open(config_dir);
}
/* Create MCP server */
g_server = cbm_mcp_server_new(NULL);
if (!g_server) {
cbm_log_error("server.err", "msg", "failed to create server");
cbm_config_close(runtime_config);
#ifndef _WIN32
if (parent_watchdog_started) {
atomic_store(&g_shutdown, 1);
cbm_thread_join(&parent_watchdog_tid);
}
#endif
return SKIP_ONE;
}
/* Create and start watcher in background thread */
/* Initialize log mutex before any threads are created */
cbm_ui_log_init();
cbm_store_t *watch_store = cbm_store_open_memory();
g_watcher = cbm_watcher_new(watch_store, watcher_index_fn, NULL);
/* Wire watcher + config into MCP server for session auto-index */
cbm_mcp_server_set_watcher(g_server, g_watcher);
cbm_mcp_server_set_config(g_server, runtime_config);
cbm_thread_t watcher_tid;
bool watcher_started = false;
if (g_watcher) {
if (cbm_thread_create(&watcher_tid, 0, watcher_thread, g_watcher) == 0) {
watcher_started = true;
}
}
/* Optionally start HTTP UI server in background thread */
cbm_thread_t http_tid;
bool http_started = false;
if (ui_cfg.ui_enabled && CBM_EMBEDDED_FILE_COUNT > 0) {
g_http_server = cbm_http_server_new(ui_cfg.ui_port);
if (g_http_server) {
cbm_http_server_set_watcher(g_http_server, g_watcher);
if (cbm_thread_create(&http_tid, 0, http_thread, g_http_server) == 0) {
http_started = true;
}
}
} else if (ui_cfg.ui_enabled && CBM_EMBEDDED_FILE_COUNT == 0) {
cbm_log_warn("ui.no_assets", "hint", "rebuild with: make -f Makefile.cbm cbm-with-ui");
}
/* Run MCP event loop (blocks until EOF or signal) */
int rc = cbm_mcp_server_run(g_server, stdin, stdout);
atomic_store(&g_shutdown, 1); /* unblock the watchdog poll loop */
/* Shutdown */
cbm_log_info("server.shutdown");
#ifndef _WIN32
if (parent_watchdog_started) {
cbm_thread_join(&parent_watchdog_tid);
}
#endif
if (http_started) {
cbm_http_server_stop(g_http_server);
cbm_thread_join(&http_tid);
cbm_http_server_free(g_http_server);
g_http_server = NULL;
}
if (watcher_started) {
cbm_watcher_stop(g_watcher);
cbm_thread_join(&watcher_tid);
}
cbm_watcher_free(g_watcher);
cbm_store_close(watch_store);
cbm_mcp_server_free(g_server);
cbm_config_close(runtime_config);
g_watcher = NULL;
g_server = NULL;
cbm_diag_stop();
return rc;
}
+230
View File
@@ -0,0 +1,230 @@
/* compact_out.c — TOON emission helpers. See compact_out.h for the contract. */
#include "mcp/compact_out.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum { SB_INITIAL_CAP = 1024 };
void cbm_sb_init(cbm_sb_t *sb) {
sb->buf = NULL;
sb->len = 0;
sb->cap = 0;
sb->oom = false;
}
static bool sb_reserve(cbm_sb_t *sb, size_t extra) {
if (sb->oom) {
return false;
}
if (sb->len + extra + 1 <= sb->cap) {
return true;
}
size_t ncap = sb->cap ? sb->cap : SB_INITIAL_CAP;
while (ncap < sb->len + extra + 1) {
ncap *= 2;
}
char *nbuf = (char *)realloc(sb->buf, ncap);
if (!nbuf) {
sb->oom = true;
return false;
}
sb->buf = nbuf;
sb->cap = ncap;
return true;
}
void cbm_sb_append_n(cbm_sb_t *sb, const char *s, size_t n) {
if (!s || n == 0 || !sb_reserve(sb, n)) {
return;
}
memcpy(sb->buf + sb->len, s, n);
sb->len += n;
sb->buf[sb->len] = '\0';
}
void cbm_sb_append(cbm_sb_t *sb, const char *s) {
if (s) {
cbm_sb_append_n(sb, s, strlen(s));
}
}
char *cbm_sb_finish(cbm_sb_t *sb) {
if (sb->oom) {
free(sb->buf);
cbm_sb_init(sb);
return NULL;
}
if (!sb->buf) {
/* Empty but valid: return an owned empty string. */
char *empty = (char *)calloc(1, 1);
return empty;
}
char *out = sb->buf;
cbm_sb_init(sb);
return out;
}
void cbm_sb_free(cbm_sb_t *sb) {
free(sb->buf);
cbm_sb_init(sb);
}
/* ── TOON quoting ───────────────────────────────────────────────── */
/* True when `s` parses as an integer or real literal (sign, digits, one dot). */
static bool looks_numeric(const char *s) {
if (!s || !*s) {
return false;
}
const char *p = s;
if (*p == '-' || *p == '+') {
p++;
}
bool digit = false;
bool dot = false;
for (; *p; p++) {
if (isdigit((unsigned char)*p)) {
digit = true;
} else if (*p == '.' && !dot) {
dot = true;
} else {
return false;
}
}
return digit;
}
static bool needs_quotes(const char *s) {
if (!s || !*s) {
return true; /* empty cell must be visible as "" */
}
if (isspace((unsigned char)s[0]) || isspace((unsigned char)s[strlen(s) - 1])) {
return true;
}
for (const char *p = s; *p; p++) {
if (*p == ',' || *p == '"' || *p == '\n' || *p == '\r') {
return true;
}
}
if (strcmp(s, "true") == 0 || strcmp(s, "false") == 0 || strcmp(s, "null") == 0) {
return true;
}
return looks_numeric(s);
}
static void append_quoted(cbm_sb_t *sb, const char *s) {
cbm_sb_append_n(sb, "\"", 1);
for (const char *p = s ? s : ""; *p; p++) {
switch (*p) {
case '"':
cbm_sb_append_n(sb, "\\\"", 2);
break;
case '\\':
cbm_sb_append_n(sb, "\\\\", 2);
break;
case '\n':
cbm_sb_append_n(sb, "\\n", 2);
break;
case '\r':
cbm_sb_append_n(sb, "\\r", 2);
break;
default:
cbm_sb_append_n(sb, p, 1);
break;
}
}
cbm_sb_append_n(sb, "\"", 1);
}
static void append_value(cbm_sb_t *sb, const char *s) {
if (needs_quotes(s)) {
append_quoted(sb, s);
} else {
cbm_sb_append(sb, s);
}
}
/* ── Scalars ────────────────────────────────────────────────────── */
void cbm_toon_scalar_str(cbm_sb_t *sb, const char *key, const char *val) {
cbm_sb_append(sb, key);
cbm_sb_append_n(sb, ": ", 2);
append_value(sb, val);
cbm_sb_append_n(sb, "\n", 1);
}
void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v) {
char num[32];
snprintf(num, sizeof(num), "%lld", v);
cbm_sb_append(sb, key);
cbm_sb_append_n(sb, ": ", 2);
cbm_sb_append(sb, num);
cbm_sb_append_n(sb, "\n", 1);
}
void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v) {
cbm_sb_append(sb, key);
cbm_sb_append_n(sb, ": ", 2);
cbm_sb_append(sb, v ? "true" : "false");
cbm_sb_append_n(sb, "\n", 1);
}
/* ── Tables ─────────────────────────────────────────────────────── */
void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols,
int ncols) {
char num[32];
snprintf(num, sizeof(num), "[%d]{", n);
cbm_sb_append(sb, key);
cbm_sb_append(sb, num);
for (int i = 0; i < ncols; i++) {
if (i > 0) {
cbm_sb_append_n(sb, ",", 1);
}
cbm_sb_append(sb, cols[i]);
}
cbm_sb_append_n(sb, "}:\n", 3);
}
void cbm_toon_row_begin(cbm_sb_t *sb) {
cbm_sb_append_n(sb, " ", 2);
}
void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first) {
if (!first) {
cbm_sb_append_n(sb, ",", 1);
}
append_value(sb, val ? val : "");
}
void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first) {
char num[32];
snprintf(num, sizeof(num), "%lld", v);
if (!first) {
cbm_sb_append_n(sb, ",", 1);
}
cbm_sb_append(sb, num);
}
void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first) {
char num[48];
snprintf(num, sizeof(num), "%.4g", v);
if (!first) {
cbm_sb_append_n(sb, ",", 1);
}
cbm_sb_append(sb, num);
}
void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first) {
if (!first) {
cbm_sb_append_n(sb, ",", 1);
}
cbm_sb_append(sb, v ? "true" : "false");
}
void cbm_toon_row_end(cbm_sb_t *sb) {
cbm_sb_append_n(sb, "\n", 1);
}
+54
View File
@@ -0,0 +1,54 @@
/* compact_out.h — TOON (Token-Oriented Object Notation) emission helpers.
*
* Tool responses are consumed by LLM agents, where every byte is context
* tokens. TOON encodes the same data as JSON but declares tabular fields
* once in a header and streams rows line by line, cutting 40-60% of tokens
* on homogeneous result sets at equal-or-better retrieval accuracy
* (toonformat.dev/guide/benchmarks). Emitters here cover the subset we
* emit: scalar key-value lines and flat tables with explicit [N] lengths.
*
* Quoting: a cell/scalar is double-quoted iff it is empty, has leading or
* trailing whitespace, contains a comma, quote, newline, or CR, or would
* read as a non-string literal (true/false/null/number). Quotes and
* backslashes are escaped JSON-style; newlines become \n.
*/
#ifndef CBM_MCP_COMPACT_OUT_H
#define CBM_MCP_COMPACT_OUT_H
#include <stdbool.h>
#include <stddef.h>
/* Minimal growing string buffer (OOM-safe: sticky flag, finish returns NULL). */
typedef struct {
char *buf;
size_t len;
size_t cap;
bool oom;
} cbm_sb_t;
void cbm_sb_init(cbm_sb_t *sb);
void cbm_sb_append_n(cbm_sb_t *sb, const char *s, size_t n);
void cbm_sb_append(cbm_sb_t *sb, const char *s);
/* Returns the heap buffer (caller frees) and resets sb. NULL on OOM. */
char *cbm_sb_finish(cbm_sb_t *sb);
void cbm_sb_free(cbm_sb_t *sb);
/* `key: value` scalar lines (top-level, no indent). */
void cbm_toon_scalar_str(cbm_sb_t *sb, const char *key, const char *val);
void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v);
void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v);
/* `key[n]{col1,col2,...}:` table header; rows follow at 2-space indent. */
void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols,
int ncols);
/* Row cells: call row_begin, then cell_* per column (first=true for the
* first cell), then row_end. Empty/NULL strings emit as empty cells. */
void cbm_toon_row_begin(cbm_sb_t *sb);
void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first);
void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first);
void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first);
void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first);
void cbm_toon_row_end(cbm_sb_t *sb);
#endif /* CBM_MCP_COMPACT_OUT_H */
+276
View File
@@ -0,0 +1,276 @@
/*
* index_supervisor.c — see index_supervisor.h.
*/
#include "index_supervisor.h"
#include "foundation/compat.h" /* cbm_setenv, cbm_unsetenv */
#include "foundation/compat_fs.h" /* cbm_mkdir_p, cbm_fopen */
#include "foundation/log.h"
#include "foundation/platform.h" /* cbm_resolve_cache_dir */
#include "foundation/profile.h" /* cbm_profile_active (keep worker log under CBM_PROFILE) */
#include "ui/http_server.h" /* cbm_http_server_resolve_binary_path */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <process.h> /* _getpid */
#define cbm_getpid _getpid
#else
#include <unistd.h> /* getpid */
#define cbm_getpid getpid
#endif
/* ── Worker-role state ────────────────────────────────────────────── */
static bool g_worker_active = false;
static char g_worker_response_out[1024] = {0};
void cbm_index_set_worker_role(bool is_worker, const char *response_out) {
g_worker_active = is_worker;
if (response_out && response_out[0]) {
snprintf(g_worker_response_out, sizeof(g_worker_response_out), "%s", response_out);
} else {
g_worker_response_out[0] = '\0';
}
}
bool cbm_index_worker_active(void) {
return g_worker_active;
}
const char *cbm_index_worker_response_out(void) {
return g_worker_response_out[0] ? g_worker_response_out : NULL;
}
/* Test hook (#845): counts spawn ATTEMPTS (entry to cbm_index_spawn_worker),
* including ones that fail to resolve the self binary — an embedder must never
* even try to spawn. */
static int g_spawn_count = 0;
int cbm_index_supervisor_spawn_count(void) {
return g_spawn_count;
}
/* Test hook: counts SINGLE-THREADED spawns. Production recovery is parallel-
* only (there are no sequential production runs); this must stay ZERO on
* every supervised path — any nonzero count means a recovery/probe regressed
* to the sequential crawl that ground an 81k-file TS corpus for hours. */
static int g_spawn_st_count = 0;
int cbm_index_supervisor_spawn_st_count(void) {
return g_spawn_st_count;
}
/* #845: opt-in host mark — see the header. Set once from the real binary's
* main(); embedders never set it, so should_wrap() stays false for them. */
static bool g_host_marked = false;
void cbm_index_supervisor_mark_host(void) {
g_host_marked = true;
}
bool cbm_index_supervisor_should_wrap(void) {
if (!g_host_marked) {
return false; /* embedder (#845): never spawn `<self> cli --index-worker` */
}
if (g_worker_active) {
return false; /* I am the worker — run in-process, never re-supervise */
}
const char *sv = getenv("CBM_INDEX_SUPERVISOR");
if (sv && strcmp(sv, "0") == 0) {
return false; /* kill switch → in-process */
}
return true;
}
/* Quiet-timeout (ms) for a supervised worker: killed + reported as a hang if it
* emits no NEW log line within the window. This is a NO-PROGRESS timeout — every
* completed log line the worker tails (per-batch parallel.extract.progress every
* 10 files, plus each pass boundary) resets it — NOT a total-time cap, so a large
* repo that keeps making progress is never falsely killed. Default: 15 min (a
* genuinely stuck file emits nothing, so this fires only on a real hang). The
* CBM_INDEX_WORKER_TIMEOUT_S override (seconds → ms) tightens it for tests. */
static int worker_quiet_timeout_ms(void) {
enum { DEFAULT_QUIET_TIMEOUT_MS = 900000 }; /* 15 min with no progress */
const char *e = getenv("CBM_INDEX_WORKER_TIMEOUT_S");
if (e && e[0]) {
long s = atol(e);
if (s > 0) {
return (int)(s * 1000);
}
}
return DEFAULT_QUIET_TIMEOUT_MS;
}
/* Read an entire file into a heap string (NUL-terminated). NULL on error. */
static char *slurp_file(const char *path) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
if (fseek(f, 0, SEEK_END) != 0) {
(void)fclose(f);
return NULL;
}
long n = ftell(f);
if (n < 0) {
(void)fclose(f);
return NULL;
}
(void)fseek(f, 0, SEEK_SET);
char *buf = (char *)malloc((size_t)n + 1);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t rd = fread(buf, 1, (size_t)n, f);
(void)fclose(f);
buf[rd] = '\0';
return buf;
}
/* Resolve a per-run temp path <cache_dir>/logs/.worker-<pid><suffix>. */
static void worker_tmp_path(char *out, size_t out_sz, int pid, const char *suffix) {
const char *cdir = cbm_resolve_cache_dir();
if (cdir && cdir[0]) {
char dir[900];
snprintf(dir, sizeof(dir), "%s/logs", cdir);
cbm_mkdir_p(dir, 0755);
snprintf(out, out_sz, "%s/.worker-%d%s", dir, pid, suffix);
} else {
snprintf(out, out_sz, ".worker-%d%s", pid, suffix);
}
}
int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file,
const char *quarantine_file, cbm_index_worker_result_t *result) {
g_spawn_count++; /* test hook (#845) — see cbm_index_supervisor_spawn_count */
if (single_thread) {
g_spawn_st_count++; /* test hook — must stay 0: recovery is parallel-only */
}
result->outcome = CBM_PROC_SPAWN_FAILED;
result->exit_code = -1;
result->term_signal = 0;
result->response = NULL;
char self[1024] = {0};
if (!cbm_http_server_resolve_binary_path(NULL, self, sizeof(self)) || !self[0]) {
cbm_log_warn("index.supervisor.no_self_path", "action", "degrade_in_process");
return -1;
}
int pid = (int)cbm_getpid();
char resp_path[1024];
char log_path[1024];
worker_tmp_path(resp_path, sizeof(resp_path), pid, ".response");
worker_tmp_path(log_path, sizeof(log_path), pid, ".log");
(void)remove(resp_path); /* clear any stale file */
/* No --progress: the worker's DEFAULT structured logging already provides the
* no-progress heartbeat (INFO parallel.extract.progress every 10 files + each
* pass boundary — all newline-terminated → tailed → reset the quiet-timeout).
* --progress would be strictly worse here: it installs a REPLACE-mode sink that
* suppresses those default lines and emits per-file extraction as a carriage-
* return in-place update (no trailing '\n'), which cbm_tail_log does not count
* as progress. (It would not corrupt the response either — that goes to the
* separate --response-out file, not stdout.) */
const char *argv[8];
int n = 0;
argv[n++] = self;
argv[n++] = "cli";
argv[n++] = "--index-worker";
argv[n++] = "index_repository";
argv[n++] = args_json;
argv[n++] = "--response-out";
argv[n++] = resp_path;
argv[n] = NULL;
/* Recovery-run probe knobs → inherited env for the child. Spawns are
* sequential, so mutating the parent's environment around a single spawn is
* safe. Set only the requested knobs; unset them all again after reaping so
* a later attempt (or the caller) starts from a clean environment. */
if (single_thread) {
cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1);
}
if (marker_file && marker_file[0]) {
cbm_setenv("CBM_INDEX_MARKER_FILE", marker_file, 1);
}
if (quarantine_file && quarantine_file[0]) {
cbm_setenv("CBM_INDEX_QUARANTINE_FILE", quarantine_file, 1);
}
cbm_proc_opts_t opts = {0};
opts.bin = self;
opts.argv = argv;
opts.log_file = log_path;
opts.quiet_timeout_ms = worker_quiet_timeout_ms();
/* We manage log deletion ourselves after reaping (below): keep it on failure
* for post-mortem, delete it only on a clean run. See the observability
* note at the reap site. */
opts.delete_log_on_exit = false;
cbm_proc_result_t r;
int run_rc = cbm_subprocess_run(&opts, &r);
if (single_thread) {
cbm_unsetenv("CBM_INDEX_SINGLE_THREAD");
}
if (marker_file && marker_file[0]) {
cbm_unsetenv("CBM_INDEX_MARKER_FILE");
}
if (quarantine_file && quarantine_file[0]) {
cbm_unsetenv("CBM_INDEX_QUARANTINE_FILE");
}
if (run_rc != 0) {
(void)remove(resp_path);
(void)remove(log_path); /* empty/partial log from a failed spawn — nothing to keep */
cbm_log_warn("index.supervisor.spawn_failed", "action", "degrade_in_process");
return -1;
}
result->outcome = r.outcome;
result->exit_code = r.exit_code;
result->term_signal = r.term_signal;
if (r.outcome == CBM_PROC_CLEAN) {
result->response = slurp_file(resp_path);
}
(void)remove(resp_path);
char sig[16];
char exit_buf[16];
snprintf(sig, sizeof(sig), "%d", r.term_signal);
snprintf(exit_buf, sizeof(exit_buf), "%d", r.exit_code);
cbm_log_info("index.supervisor.reap", "outcome", cbm_proc_outcome_str(r.outcome), "exit_code",
exit_buf, "signal", sig);
/* Observability: on a CLEAN run the worker log is noise → delete it. On
* ANY failure keep it and surface its path + raw exit code, so the worker's own
* stdout/stderr (pipeline logs, any assert/abort text, the exact exit code) is
* available post-mortem instead of vanishing. Previously the log was ALWAYS
* deleted and only outcome+signal were logged, so a worker that exited non-zero
* left nothing to diagnose — the CI blind spot that hid this bug (a mangled JSON
* arg → "repo_path is required" exit) behind a generic "crashed on a file".
*
* Exception: under CBM_PROFILE the log IS the deliverable — the worker's
* msg=prof pass/sub-phase report is only written there, and deleting it on
* success made profiling clean runs impossible. Keep it and say where it is. */
if (r.outcome == CBM_PROC_CLEAN && !cbm_profile_active) {
(void)remove(log_path);
} else if (r.outcome == CBM_PROC_CLEAN) {
cbm_log_info("index.supervisor.profile_log", "log", log_path);
} else {
cbm_log_warn("index.supervisor.worker_failed", "outcome", cbm_proc_outcome_str(r.outcome),
"exit_code", exit_buf, "log", log_path);
}
return 0;
}
void cbm_index_worker_result_free(cbm_index_worker_result_t *result) {
if (result) {
free(result->response);
result->response = NULL;
}
}
+90
View File
@@ -0,0 +1,90 @@
/*
* index_supervisor.h — run index_repository in a supervised worker subprocess.
*
* A single pathological file can hard-crash (SIGSEGV / stack overflow / abort) or
* hang the native indexer, and today that takes down the whole MCP server or CLI.
* The supervisor runs the actual index in a CHILD process (the same binary
* re-invoked as `cli --index-worker index_repository …`), reaps it, and classifies
* how it ended. A crash/hang is contained to the child; the parent survives and
* reports it instead of dying.
*
* This module owns only the spawn/reap MECHANISM and the worker-role state. The
* MCP handler (mcp.c) owns the gate placement and the response building, so this
* module has no dependency on the response format.
*
* fork+exec only (never fork-and-run-in-child): the server holds persistent
* threads plus mimalloc/sqlite global state with no pthread_atfork, so a
* fork without exec would be a latent deadlock. Recursion is prevented by an argv
* flag (`--index-worker`), never an ambient env var.
*/
#ifndef CBM_INDEX_SUPERVISOR_H
#define CBM_INDEX_SUPERVISOR_H
#include <stdbool.h>
#include "foundation/subprocess.h" /* cbm_proc_outcome_t */
/* Worker-role state, set once from the CLI arg parser (main.c) when this process
* was spawned as a supervised worker. When active, indexing must run in-process
* (the gate must NOT re-supervise). response_out (may be NULL) is the file the
* worker writes its final result string to, for the parent to read back. */
void cbm_index_set_worker_role(bool is_worker, const char *response_out);
bool cbm_index_worker_active(void);
const char *cbm_index_worker_response_out(void);
/* Host marking (#845): the supervisor gate is OPT-IN per process. Only the real
* codebase-memory-mcp binary calls this (first thing in main(), before any
* subcommand dispatch, so MCP server + CLI + HTTP paths are all covered).
* EMBEDDERS of cbm_mcp_handle_tool (test binaries, future library users) never
* call it, so they index in-process by default. Without this gate the supervisor
* resolved the CURRENT executable and re-invoked it as
* `<self> cli --index-worker …` — a test binary ignores those args and re-runs
* its suites instead, producing recursive spawn chains (11-min hangs; kernel
* VM-map pressure during the 2026-07-04 host panics). */
void cbm_index_supervisor_mark_host(void);
/* True when handle_index_repository should wrap the run in a supervised child:
* this process called cbm_index_supervisor_mark_host() (i.e. it IS the real
* binary, not an embedder), is not itself a worker, AND the kill switch
* (CBM_INDEX_SUPERVISOR=0) is not set. */
bool cbm_index_supervisor_should_wrap(void);
/* TEST HOOK (#845): process-wide count of worker-spawn attempts, incremented on
* entry to cbm_index_spawn_worker. Embedder tests assert the count is unchanged
* across an index_repository call to prove indexing ran IN-PROCESS. */
int cbm_index_supervisor_spawn_count(void);
/* Test hook: single-threaded spawn count — must stay ZERO (production
* recovery is parallel-only; no sequential runs). */
int cbm_index_supervisor_spawn_st_count(void);
typedef struct {
cbm_proc_outcome_t outcome; /* how the worker ended */
int exit_code; /* worker exit code (-1 if signalled) */
int term_signal; /* POSIX terminating signal, else 0 */
char *response; /* worker's result string on CLEAN exit (caller frees); else NULL */
} cbm_index_worker_result_t;
/* Spawn `<self> cli --index-worker index_repository <args_json> --response-out <tmp>`,
* supervise it (quiet-timeout for hangs), reap, and classify. On a clean exit,
* result->response holds the worker's response string (read from the temp file).
* Returns 0 if a worker was spawned and reaped (result filled), or -1 if the
* child could not be spawned (caller degrades to in-process).
*
* Probe knobs for the skip-and-continue recovery re-run (Stage 3c) are passed to
* the child as inherited env vars around the spawn (set before, unset after —
* safe because spawns are sequential):
* - single_thread → CBM_INDEX_SINGLE_THREAD=1: the pipeline uses exactly one
* worker, so a per-file marker pins the EXACT crasher.
* - marker_file → CBM_INDEX_MARKER_FILE: the worker writes the rel_path of
* the file it is about to process here before touching it.
* - quarantine_file → CBM_INDEX_QUARANTINE_FILE: newline-delimited rel_paths to
* skip and report as phase="crash".
* Any of the three may be false/NULL to leave that knob unset (the normal first
* attempt passes single_thread=false, marker_file=NULL, quarantine_file=NULL). */
int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file,
const char *quarantine_file, cbm_index_worker_result_t *result);
void cbm_index_worker_result_free(cbm_index_worker_result_t *result);
#endif /* CBM_INDEX_SUPERVISOR_H */
+7613
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/*
* mcp.h — MCP (Model Context Protocol) server for codebase-memory-mcp.
*
* Implements JSON-RPC 2.0 over stdio with the MCP tool calling protocol.
* Provides 14 graph analysis tools (search, trace, query, index, etc.)
*/
#ifndef CBM_MCP_H
#define CBM_MCP_H
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
/* ── Forward declarations ─────────────────────────────────────── */
typedef struct cbm_store cbm_store_t; /* from store/store.h */
struct cbm_watcher; /* from watcher/watcher.h */
struct cbm_config; /* from cli/cli.h */
/* ── JSON-RPC types ───────────────────────────────────────────── */
typedef struct {
const char *jsonrpc; /* "2.0" */
const char *method; /* e.g. "initialize", "tools/call" */
int64_t id; /* request ID (numeric form; -1 if notification) */
const char *id_str; /* non-NULL when id is a JSON string (issue #253) */
bool has_id; /* false for notifications */
const char *params_raw; /* raw JSON string of params */
} cbm_jsonrpc_request_t;
typedef struct {
int64_t id;
const char *id_str; /* non-NULL to echo a string id verbatim (issue #253) */
const char *result_json; /* JSON string for result (success) */
const char *error_json; /* JSON string for error (failure), NULL on success */
int error_code; /* JSON-RPC error code */
} cbm_jsonrpc_response_t;
/* ── JSON-RPC parsing / formatting ────────────────────────────── */
/* Parse a JSON-RPC request line. Returns 0 on success, -1 on error.
* Caller must call cbm_jsonrpc_request_free(). */
int cbm_jsonrpc_parse(const char *line, cbm_jsonrpc_request_t *out);
void cbm_jsonrpc_request_free(cbm_jsonrpc_request_t *r);
/* Format a JSON-RPC response. Returns heap-allocated JSON string. */
char *cbm_jsonrpc_format_response(const cbm_jsonrpc_response_t *resp);
/* Format a JSON-RPC error response. Returns heap-allocated JSON string. */
char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message);
/* ── MCP protocol helpers ─────────────────────────────────────── */
/* Format an MCP tool result with text content. Returns heap-allocated JSON. */
char *cbm_mcp_text_result(const char *text, bool is_error);
/* Return true when notifications/cancelled params target the active request. */
bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
const char *active_id_str);
/* Format the tools/list response. Returns heap-allocated JSON. */
char *cbm_mcp_tools_list(void);
/* Return a tool's JSON input_schema string by name (static; do not free), or
* NULL if the tool is unknown. Backs the CLI flag parser + per-tool --help. */
const char *cbm_mcp_tool_input_schema(const char *tool_name);
/* Format the initialize response. params_json is the raw initialize params
* (used for protocol version negotiation). Returns heap-allocated JSON. */
char *cbm_mcp_initialize_response(const char *params_json);
/* ── Tool argument helpers ────────────────────────────────────── */
/* Extract a string argument from the tools/call params JSON.
* Returns heap-allocated copy, or NULL if not found. */
char *cbm_mcp_get_string_arg(const char *args_json, const char *key);
/* Extract an int argument. Returns default_val if not found. */
int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val);
/* Extract a bool argument. Returns false if not found. */
bool cbm_mcp_get_bool_arg(const char *args_json, const char *key);
/* Extract the tool name from a tools/call params JSON. Heap-allocated. */
char *cbm_mcp_get_tool_name(const char *params_json);
/* Extract the arguments sub-object from tools/call params. Heap-allocated JSON string. */
char *cbm_mcp_get_arguments(const char *params_json);
/* ── MCP Server ───────────────────────────────────────────────── */
typedef struct cbm_mcp_server cbm_mcp_server_t;
/* Create an MCP server. store_path is the SQLite database directory. */
cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path);
/* Free an MCP server. */
void cbm_mcp_server_free(cbm_mcp_server_t *srv);
/* Set external watcher reference (for auto-index registration). Not owned. */
void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w);
/* Set external config store reference (for auto_index setting). Not owned. */
void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg);
/* Run the MCP server event loop on the given streams (typically stdin/stdout).
* Blocks until EOF on input. Returns 0 on success, -1 on error. */
int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out);
/* Process a single JSON-RPC request line and return the response.
* Returns heap-allocated JSON response string, or NULL for notifications. */
char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line);
/* ── Tool handler dispatch (for testing) ──────────────────────── */
/* Handle a tools/call request. Returns MCP tool result JSON. */
char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json);
/* ── Supervised background index (RSS isolation, #832) ────────── */
/* Run a full index of root_path in a supervised worker SUBPROCESS (the same
* crash/hang-isolating runner used by handle_index_repository), so the child
* returns 100% of its RSS to the OS on exit instead of ratcheting the long-lived
* parent. Builds {"repo_path": root_path} internally. Returns the worker's
* response string (caller frees) on success, or NULL to signal the caller must
* degrade to the in-process path (kill switch set, spawn failure, or the process
* is not a supervisor host). This is the shared entry the watcher re-index
* (main.c) and the session auto-index (mcp.c) route through. */
char *cbm_mcp_index_run_supervised_path(const char *root_path);
/* ── Idle store eviction ──────────────────────────────────────── */
/* Evict the cached project store if idle for more than timeout_s seconds.
* Protects initial in-memory stores (those never accessed via a named project).
* Called automatically by the event loop on poll() timeout. */
void cbm_mcp_server_evict_idle(cbm_mcp_server_t *srv, int timeout_s);
/* Check if the server currently has a cached store open. */
bool cbm_mcp_server_has_cached_store(cbm_mcp_server_t *srv);
/* ── Testing helpers ───────────────────────────────────────────── */
/* Get the store handle from a server (for test setup). */
cbm_store_t *cbm_mcp_server_store(cbm_mcp_server_t *srv);
/* Set the project name associated with the server's current store (for test setup).
* This prevents resolve_store() from trying to open a .db file when tools specify a project. */
void cbm_mcp_server_set_project(cbm_mcp_server_t *srv, const char *project);
/* ── Cancellation support ─────────────────────────────────────── */
struct cbm_pipeline; /* forward decl */
/* Get the currently active pipeline (for signal handler cancellation).
* Returns NULL if no pipeline is running. */
struct cbm_pipeline *cbm_mcp_server_active_pipeline(cbm_mcp_server_t *srv);
/* ── URI helpers ───────────────────────────────────────────────── */
/* Parse a file:// URI and extract the filesystem path.
* Writes to out_path (up to out_size bytes). Returns true on success.
* On Windows, strips leading / from /C:/path. */
bool cbm_parse_file_uri(const char *uri, char *out_path, int out_size);
#endif /* CBM_MCP_H */
+795
View File
@@ -0,0 +1,795 @@
/*
* artifact.c — Persistent artifact export/import for team sharing.
*
* Export: strip indexes → VACUUM INTO temp → zstd compress → write .zst + metadata
* Import: decompress → write to cache → open (auto-creates indexes) → integrity check
*/
#include "foundation/constants.h"
enum {
ART_DIR_PERMS = 0755,
ART_ZSTD_FAST = 3,
ART_ZSTD_BEST = 9,
ART_RATIO_SCALE = 10, /* multiply ratio by 10 for integer logging */
ART_NUL = 1, /* NUL terminator byte */
};
#define ART_BYTES_PER_MB ((size_t)1024 * 1024)
/* Generous ceiling on an imported artifact's decompressed size. Real indexes
* (a full Linux-kernel DB is ~14 GB) fit comfortably; a frame that declares
* more than this is rejected before any allocation so a crafted content size
* can neither trigger a runaway allocation nor be used to desync the decoder
* capacity from the destination buffer. */
#define ART_MAX_DECOMPRESSED_BYTES ((size_t)64 * 1024 * ART_BYTES_PER_MB)
#include "pipeline/artifact.h"
#include "store/store.h"
#include "foundation/platform.h"
#include "foundation/compat_fs.h"
#include "foundation/compat.h"
#include "foundation/log.h"
#include "foundation/str_util.h" /* cbm_validate_shell_arg — git shell-out hardening */
#include "zstd_store.h"
#include <sqlite3.h>
#include <yyjson/yyjson.h>
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#ifdef _WIN32
#include <windows.h>
#endif
/* ── Helpers ──────────────────────────────────────────────────────── */
/* Thread-local rotating buffers for small int→string conversions (logging).
* Rotating allows multiple itoa_buf() calls in a single log statement. */
enum { ART_RING = 4, ART_RING_MASK = 3 };
static _Thread_local char g_export_error[CBM_SZ_512];
static const char *itoa_buf(int v) {
static _Thread_local char bufs[ART_RING][CBM_SZ_32];
static _Thread_local int idx = 0;
int i = idx;
idx = (idx + ART_NUL) & ART_RING_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", v);
return bufs[i];
}
const char *cbm_artifact_export_last_error(void) {
return g_export_error[0] ? g_export_error : NULL;
}
static void clear_export_error(void) {
g_export_error[0] = '\0';
}
static int artifact_export_fail(const char *stage, const char *path, const char *err, int err_no) {
const char *safe_stage = stage ? stage : "unknown";
const char *safe_err = err ? err : "unknown";
if (path && err_no != 0) {
snprintf(g_export_error, sizeof(g_export_error), "%s: %s errno=%d path=%s", safe_stage,
safe_err, err_no, path);
} else if (path) {
snprintf(g_export_error, sizeof(g_export_error), "%s: %s path=%s", safe_stage, safe_err,
path);
} else if (err_no != 0) {
snprintf(g_export_error, sizeof(g_export_error), "%s: %s errno=%d", safe_stage, safe_err,
err_no);
} else {
snprintf(g_export_error, sizeof(g_export_error), "%s: %s", safe_stage, safe_err);
}
if (path && err_no != 0) {
cbm_log_error("artifact.export", "stage", safe_stage, "err", safe_err, "errno",
itoa_buf(err_no), "path", path);
} else if (path) {
cbm_log_error("artifact.export", "stage", safe_stage, "err", safe_err, "path", path);
} else if (err_no != 0) {
cbm_log_error("artifact.export", "stage", safe_stage, "err", safe_err, "errno",
itoa_buf(err_no));
} else {
cbm_log_error("artifact.export", "stage", safe_stage, "err", safe_err);
}
return CBM_NOT_FOUND;
}
typedef struct {
const char *err;
int err_no;
} artifact_file_error_t;
static void file_error_clear(artifact_file_error_t *out) {
if (out) {
out->err = NULL;
out->err_no = 0;
}
}
static void file_error_set(artifact_file_error_t *out, const char *err, int err_no) {
if (out) {
out->err = err;
out->err_no = err_no;
}
}
/* Build path: <repo>/.codebase-memory/<name> into caller-owned buf. */
static bool artifact_path(char *buf, size_t bufsz, const char *repo_path, const char *name) {
int n = snprintf(buf, bufsz, "%s/%s/%s", repo_path, CBM_ARTIFACT_DIR, name);
return n >= 0 && (size_t)n < bufsz;
}
/* Read entire file into malloc'd buffer. Sets *out_len. Returns NULL on error. */
static char *read_file_alloc(const char *path, size_t *out_len) {
FILE *fp = cbm_fopen(path, "rb");
if (!fp) {
return NULL;
}
(void)fseek(fp, 0, SEEK_END);
long sz = ftell(fp);
if (sz <= 0) {
(void)fclose(fp);
return NULL;
}
(void)fseek(fp, 0, SEEK_SET);
char *buf = malloc((size_t)sz);
if (!buf) {
(void)fclose(fp);
return NULL;
}
size_t rd = fread(buf, ART_NUL, (size_t)sz, fp);
(void)fclose(fp);
if ((long)rd != sz) {
free(buf);
return NULL;
}
*out_len = (size_t)sz;
return buf;
}
/* Write buffer to file atomically (write to tmp, rename). Returns 0 on success. */
static int write_file_atomic(const char *path, const char *data, size_t len,
artifact_file_error_t *out_err) {
file_error_clear(out_err);
char tmp[CBM_SZ_4K];
int n = snprintf(tmp, sizeof(tmp), "%s.tmp", path);
if (n < 0 || (size_t)n >= sizeof(tmp)) {
file_error_set(out_err, "path_too_long", 0);
return CBM_NOT_FOUND;
}
FILE *fp = fopen(tmp, "wb");
if (!fp) {
file_error_set(out_err, "open_temp", errno);
return CBM_NOT_FOUND;
}
size_t wr = fwrite(data, ART_NUL, len, fp);
if (wr != len) {
int saved_errno = ferror(fp) ? errno : 0;
(void)fclose(fp);
cbm_unlink(tmp);
file_error_set(out_err, "write_temp", saved_errno);
return CBM_NOT_FOUND;
}
if (fclose(fp) != 0) {
int saved_errno = errno;
cbm_unlink(tmp);
file_error_set(out_err, "close_temp", saved_errno);
return CBM_NOT_FOUND;
}
#ifdef _WIN32
/* MoveFileEx replace approach suggested by @Ayush7Ranjan in #492. */
if (!MoveFileExA(tmp, path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) {
DWORD saved_error = GetLastError();
cbm_unlink(tmp);
file_error_set(out_err, "rename_temp", (int)saved_error);
return CBM_NOT_FOUND;
}
#else
if (rename(tmp, path) != 0) {
int saved_errno = errno;
cbm_unlink(tmp);
file_error_set(out_err, "rename_temp", saved_errno);
return CBM_NOT_FOUND;
}
#endif
return 0;
}
#ifdef _WIN32
#define ARTIFACT_NULL_DEV "NUL"
#else
#define ARTIFACT_NULL_DEV "/dev/null"
#endif
/* See artifact.h. Mirrors git_context.c's git_validate_repo_path (the best-hardened
* git shell-out): cbm_validate_shell_arg rejects quote / backslash / substitution
* metacharacters, and on Windows we also reject the cmd.exe expansion metacharacters
* % ! ^. Callers then use DOUBLE quotes (honored by both POSIX sh and cmd.exe, unlike
* single quotes on cmd.exe), so a repo path may legitimately contain spaces. */
bool cbm_artifact_repo_path_is_shell_safe(const char *repo_path) {
if (!cbm_validate_shell_arg(repo_path)) {
return false;
}
#ifdef _WIN32
for (const char *p = repo_path; *p; p++) {
if (*p == '%' || *p == '!' || *p == '^') {
return false;
}
}
#endif
return true;
}
/* Get current git HEAD hash. buf must be >= CBM_SZ_64. Returns false on error. */
static bool git_head_hash(const char *repo_path, char *buf, size_t bufsz) {
char cmd[CBM_SZ_1K];
if (!cbm_artifact_repo_path_is_shell_safe(repo_path)) {
buf[0] = '\0';
return false;
}
int n =
snprintf(cmd, sizeof(cmd), "git -C \"%s\" rev-parse HEAD 2>" ARTIFACT_NULL_DEV, repo_path);
if (n < 0 || (size_t)n >= sizeof(cmd)) {
buf[0] = '\0'; /* truncated command → don't run a malformed shell string (parity with
git_context.c) */
return false;
}
FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
buf[0] = '\0';
return false;
}
buf[0] = '\0';
if (fgets(buf, (int)bufsz, fp)) {
/* Strip trailing newline */
size_t len = strlen(buf);
while (len > 0 && (buf[len - ART_NUL] == '\n' || buf[len - ART_NUL] == '\r')) {
buf[--len] = '\0';
}
}
(void)cbm_pclose(fp);
return buf[0] != '\0';
}
/* Generate ISO 8601 timestamp into buf. */
static void iso_timestamp(char *buf, size_t bufsz) {
time_t now = time(NULL);
struct tm tm;
#ifdef _WIN32
gmtime_s(&tm, &now);
#else
gmtime_r(&now, &tm);
#endif
(void)strftime(buf, bufsz, "%Y-%m-%dT%H:%M:%SZ", &tm);
}
/* ── Metadata read/write ─────────────────────────────────────────── */
/* Read schema_version from artifact.json. Returns -1 if missing/invalid. */
static int read_metadata_version(const char *repo_path) {
char meta_path[CBM_SZ_4K];
artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META);
size_t len = 0;
char *json = read_file_alloc(meta_path, &len);
if (!json) {
return CBM_NOT_FOUND;
}
yyjson_doc *doc = yyjson_read(json, len, 0);
free(json);
if (!doc) {
return CBM_NOT_FOUND;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *ver = yyjson_obj_get(root, "schema_version");
int version = ver ? yyjson_get_int(ver) : CBM_NOT_FOUND;
yyjson_doc_free(doc);
return version;
}
/* Read original_size from artifact.json. Returns 0 on error. */
static size_t read_metadata_original_size(const char *repo_path) {
char meta_path[CBM_SZ_4K];
artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META);
size_t len = 0;
char *json = read_file_alloc(meta_path, &len);
if (!json) {
return 0;
}
yyjson_doc *doc = yyjson_read(json, len, 0);
free(json);
if (!doc) {
return 0;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *val = yyjson_obj_get(root, "original_size");
size_t result = val ? (size_t)yyjson_get_uint(val) : 0;
yyjson_doc_free(doc);
return result;
}
/* Write artifact.json metadata. */
static int write_metadata(const char *repo_path, const char *project_name, int nodes, int edges,
size_t original_size, size_t compressed_size, int compression_level) {
char commit[CBM_SZ_64] = "";
git_head_hash(repo_path, commit, sizeof(commit));
char ts[CBM_SZ_64];
iso_timestamp(ts, sizeof(ts));
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_obj_add_int(doc, root, "schema_version", CBM_ARTIFACT_SCHEMA_VERSION);
yyjson_mut_obj_add_str(doc, root, "commit", commit);
yyjson_mut_obj_add_str(doc, root, "indexed_at", ts);
yyjson_mut_obj_add_str(doc, root, "project", project_name);
yyjson_mut_obj_add_int(doc, root, "nodes", nodes);
yyjson_mut_obj_add_int(doc, root, "edges", edges);
yyjson_mut_obj_add_uint(doc, root, "original_size", (uint64_t)original_size);
yyjson_mut_obj_add_uint(doc, root, "compressed_size", (uint64_t)compressed_size);
yyjson_mut_obj_add_int(doc, root, "compression_level", compression_level);
size_t json_len = 0;
char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len);
yyjson_mut_doc_free(doc);
if (!json) {
return artifact_export_fail("write_metadata", NULL, "json_encode", 0);
}
char meta_path[CBM_SZ_4K];
if (!artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META)) {
free(json);
return artifact_export_fail("write_metadata", repo_path, "path_too_long", 0);
}
artifact_file_error_t ioerr;
int rc = write_file_atomic(meta_path, json, json_len, &ioerr);
free(json);
if (rc != 0) {
return artifact_export_fail("write_metadata", meta_path, ioerr.err, ioerr.err_no);
}
return rc;
}
/* ── .gitattributes setup ────────────────────────────────────────── */
static void ensure_gitattributes(const char *repo_path) {
char ga_path[CBM_SZ_4K];
artifact_path(ga_path, sizeof(ga_path), repo_path, ".gitattributes");
/* Atomic create-only-if-absent: O_EXCL closes the TOCTOU window
* between checking existence and writing. If the file exists, open
* fails with EEXIST and we leave it untouched. */
int fd = open(ga_path, O_WRONLY | O_CREAT | O_EXCL, 0644);
if (fd < 0) {
if (errno != EEXIST) {
cbm_log_warn("artifact.gitattributes.open path=%s err=%s", ga_path, strerror(errno));
}
/* fall through to merge driver setup either way */
} else {
FILE *fp = fdopen(fd, "w");
if (fp) {
/* Order matters: attributes apply left to right and the `binary`
* macro expands to `-diff -merge -text`, so a trailing `binary`
* unsets `merge=ours` and the conflict prevention this file
* exists for never engages. The macro must come first. */
(void)fputs("# Auto-generated by codebase-memory-mcp\n"
"# Prevent merge conflicts on compressed artifact\n" CBM_ARTIFACT_FILENAME
" binary merge=ours\n",
fp);
(void)fclose(fp);
} else {
(void)close(fd);
}
}
/* Best-effort: configure merge driver */
if (!cbm_artifact_repo_path_is_shell_safe(repo_path)) {
return;
}
char cmd[CBM_SZ_1K];
int n = snprintf(cmd, sizeof(cmd),
"git -C \"%s\" config merge.ours.driver true 2>" ARTIFACT_NULL_DEV, repo_path);
if (n < 0 || (size_t)n >= sizeof(cmd)) {
return; /* truncated command → skip (parity with git_context.c) */
}
FILE *p = cbm_popen(cmd, "r");
if (p) {
(void)cbm_pclose(p);
}
}
/* ── Index stripping ─────────────────────────────────────────────── */
/* SQL to drop all user-created indexes (not autoindexes, not FTS5). */
static const char *DROP_INDEXES_SQL = "DROP INDEX IF EXISTS idx_nodes_label;"
"DROP INDEX IF EXISTS idx_nodes_name;"
"DROP INDEX IF EXISTS idx_nodes_file;"
"DROP INDEX IF EXISTS idx_edges_source;"
"DROP INDEX IF EXISTS idx_edges_target;"
"DROP INDEX IF EXISTS idx_edges_type;"
"DROP INDEX IF EXISTS idx_edges_target_type;"
"DROP INDEX IF EXISTS idx_edges_source_type;"
"DROP INDEX IF EXISTS idx_edges_url_path;";
/* ── Export helpers ───────────────────────────────────────────────── */
/* Prepare a stripped DB copy for best-quality export.
* VACUUM INTO → (optionally) drop indexes → VACUUM. Returns malloc'd buffer
* or NULL. VACUUM INTO runs on BOTH quality levels: it is the consistent
* snapshot — the store runs in WAL mode, so raw main-file bytes miss
* committed transactions still in the -wal and can be mid-checkpoint torn
* (#895). Only the index-stripping is BEST-only. */
static char *prepare_snapshot_db(const char *db_path, size_t *out_size, bool strip_indexes) {
char tmp_path[CBM_SZ_4K];
snprintf(tmp_path, sizeof(tmp_path), "%s/cbm_artifact_tmp.db", cbm_tmpdir());
cbm_unlink(tmp_path);
/* VACUUM INTO: clean compacted copy. Use raw sqlite3 to bypass store authorizer
* (which blocks ATTACH, used internally by VACUUM INTO). */
sqlite3 *raw_db = NULL;
if (sqlite3_open_v2(db_path, &raw_db, SQLITE_OPEN_READWRITE, NULL) != SQLITE_OK) {
const char *err = raw_db ? sqlite3_errmsg(raw_db) : "sqlite_open";
artifact_export_fail("open_source_db", db_path, err, 0);
sqlite3_close(raw_db);
return NULL;
}
char vacuum_sql[CBM_SZ_4K];
snprintf(vacuum_sql, sizeof(vacuum_sql), "VACUUM INTO '%s';", tmp_path);
char *errmsg = NULL;
int vrc = sqlite3_exec(raw_db, vacuum_sql, NULL, NULL, &errmsg);
sqlite3_close(raw_db);
if (vrc != SQLITE_OK) {
artifact_export_fail("vacuum_into", tmp_path, errmsg ? errmsg : sqlite3_errstr(vrc), 0);
sqlite3_free(errmsg);
cbm_unlink(tmp_path);
return NULL;
}
/* Strip indexes from the copy for better compression (BEST only). */
if (strip_indexes) {
sqlite3 *tmp_db = NULL;
if (sqlite3_open_v2(tmp_path, &tmp_db, SQLITE_OPEN_READWRITE, NULL) == SQLITE_OK) {
sqlite3_exec(tmp_db, DROP_INDEXES_SQL, NULL, NULL, NULL);
sqlite3_exec(tmp_db, "VACUUM;", NULL, NULL, NULL);
sqlite3_close(tmp_db);
}
}
char *data = read_file_alloc(tmp_path, out_size);
if (!data || *out_size == 0) {
artifact_export_fail("read_stripped_db", tmp_path, "empty_or_unreadable", errno);
}
cbm_unlink(tmp_path);
/* Clean up WAL/SHM from temp */
char wal[CBM_SZ_4K];
char shm[CBM_SZ_4K];
snprintf(wal, sizeof(wal), "%s-wal", tmp_path);
snprintf(shm, sizeof(shm), "%s-shm", tmp_path);
cbm_unlink(wal);
cbm_unlink(shm);
return data;
}
/* ── Export ───────────────────────────────────────────────────────── */
int cbm_artifact_export(const char *db_path, const char *repo_path, const char *project_name,
int quality) {
clear_export_error();
if (!db_path || !repo_path || !project_name) {
return artifact_export_fail("validate_args", NULL, "missing_argument", 0);
}
/* Ensure .codebase-memory/ directory exists */
char art_dir[CBM_SZ_4K];
int dir_len = snprintf(art_dir, sizeof(art_dir), "%s/%s", repo_path, CBM_ARTIFACT_DIR);
if (dir_len < 0 || (size_t)dir_len >= sizeof(art_dir)) {
return artifact_export_fail("prepare_artifact_dir", repo_path, "path_too_long", 0);
}
errno = 0;
if (!cbm_mkdir_p(art_dir, ART_DIR_PERMS)) {
return artifact_export_fail("prepare_artifact_dir", art_dir, "mkdir_or_not_directory",
errno);
}
if (!cbm_is_dir(art_dir)) {
return artifact_export_fail("prepare_artifact_dir", art_dir, "not_directory", 0);
}
size_t db_size = 0;
char *db_data = NULL;
int compression_level = ART_ZSTD_FAST;
if (quality == CBM_ARTIFACT_BEST) {
compression_level = ART_ZSTD_BEST;
db_data = prepare_snapshot_db(db_path, &db_size, true);
} else {
/* FAST keeps zstd-3 and its indexes, but still snapshots via
* VACUUM INTO: the raw main-file bytes of a live WAL store are a
* torn copy (#895). */
db_data = prepare_snapshot_db(db_path, &db_size, false);
}
if (!db_data || db_size == 0) {
free(db_data);
if (cbm_artifact_export_last_error()) {
return CBM_NOT_FOUND;
}
return artifact_export_fail("read_db", db_path, "empty_or_unreadable", errno);
}
/* Compress with zstd */
size_t bound = cbm_zstd_compress_bound((int)db_size);
char *compressed = malloc(bound);
if (!compressed) {
free(db_data);
return artifact_export_fail("compress", NULL, "alloc_compressed_buffer", 0);
}
int clen = cbm_zstd_compress(db_data, (int)db_size, compressed, (int)bound, compression_level);
free(db_data);
if (clen <= 0) {
free(compressed);
return artifact_export_fail("compress", NULL, "zstd_compress", 0);
}
/* Write compressed artifact */
char zst_path[CBM_SZ_4K];
if (!artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME)) {
free(compressed);
return artifact_export_fail("write_artifact", repo_path, "path_too_long", 0);
}
artifact_file_error_t ioerr;
int wrc = write_file_atomic(zst_path, compressed, (size_t)clen, &ioerr);
free(compressed);
if (wrc != 0) {
return artifact_export_fail("write_artifact", zst_path, ioerr.err, ioerr.err_no);
}
/* Get node/edge counts for metadata */
int nodes = 0;
int edges = 0;
cbm_store_t *count_store = cbm_store_open_path(db_path);
if (count_store) {
nodes = cbm_store_count_nodes(count_store, project_name);
edges = cbm_store_count_edges(count_store, project_name);
cbm_store_close(count_store);
}
/* Write metadata */
if (write_metadata(repo_path, project_name, nodes, edges, db_size, (size_t)clen,
compression_level) != 0) {
cbm_unlink(zst_path);
return CBM_NOT_FOUND;
}
/* Ensure .gitattributes for merge conflict prevention */
ensure_gitattributes(repo_path);
double ratio = db_size > 0 ? (double)db_size / (double)clen : 0.0;
cbm_log_info("artifact.export", "quality", quality == CBM_ARTIFACT_BEST ? "best" : "fast",
"original_mb", itoa_buf((int)(db_size / ART_BYTES_PER_MB)), "compressed_mb",
itoa_buf((int)((size_t)clen / ART_BYTES_PER_MB)), "ratio",
itoa_buf((int)(ratio * ART_RATIO_SCALE)));
return 0;
}
/* ── Import ──────────────────────────────────────────────────────── */
int cbm_artifact_import(const char *repo_path, const char *cache_db_path) {
if (!repo_path || !cache_db_path) {
return CBM_NOT_FOUND;
}
/* Check schema version compatibility */
int version = read_metadata_version(repo_path);
if (version < 0 || version > CBM_ARTIFACT_SCHEMA_VERSION) {
cbm_log_info("artifact.import", "skip", "schema_version_mismatch", "artifact_ver",
itoa_buf(version), "current_ver", itoa_buf(CBM_ARTIFACT_SCHEMA_VERSION));
return CBM_NOT_FOUND;
}
/* Get original_size for decompression buffer */
size_t original_size = read_metadata_original_size(repo_path);
if (original_size == 0) {
cbm_log_error("artifact.import", "err", "missing_original_size");
return CBM_NOT_FOUND;
}
/* Read compressed artifact */
char zst_path[CBM_SZ_4K];
artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME);
size_t clen = 0;
char *compressed = read_file_alloc(zst_path, &clen);
if (!compressed) {
cbm_log_error("artifact.import", "err", "read_artifact");
return CBM_NOT_FOUND;
}
/* Decompress */
/* Size the destination from the zstd frame's own content-size header, not
* from the separately-stored (attacker-controllable) original_size field.
* The allocation and the decoder capacity are then the SAME size_t value,
* so a crafted size can never make the capacity exceed the real buffer
* (the int-truncation that used to do exactly that is gone with the size_t
* signature). Require the metadata field to agree, and cap the total. */
size_t frame_size = cbm_zstd_frame_content_size(compressed, clen);
if (frame_size == 0 || frame_size > ART_MAX_DECOMPRESSED_BYTES || frame_size != original_size) {
free(compressed);
cbm_log_error("artifact.import", "err", "bad_decompressed_size");
return CBM_NOT_FOUND;
}
char *decompressed = malloc(frame_size);
if (!decompressed) {
free(compressed);
return CBM_NOT_FOUND;
}
int64_t dlen = cbm_zstd_decompress(compressed, clen, decompressed, frame_size);
free(compressed);
if (dlen <= 0 || (size_t)dlen != frame_size) {
free(decompressed);
cbm_log_error("artifact.import", "err", "zstd_decompress");
return CBM_NOT_FOUND;
}
/* Write to temp file, then rename for atomicity */
char tmp_path[CBM_SZ_4K];
snprintf(tmp_path, sizeof(tmp_path), "%s.import_tmp", cache_db_path);
/* Ensure cache directory exists */
char cache_dir[CBM_SZ_1K];
snprintf(cache_dir, sizeof(cache_dir), "%s", cache_db_path);
char *last_slash = strrchr(cache_dir, '/');
if (last_slash) {
*last_slash = '\0';
cbm_mkdir_p(cache_dir, ART_DIR_PERMS);
}
artifact_file_error_t ioerr;
int wrc = write_file_atomic(tmp_path, decompressed, (size_t)dlen, &ioerr);
free(decompressed);
if (wrc != 0) {
if (ioerr.err_no != 0) {
cbm_log_error("artifact.import", "err", "write_temp_db", "detail", ioerr.err, "errno",
itoa_buf(ioerr.err_no), "path", tmp_path);
} else {
cbm_log_error("artifact.import", "err", "write_temp_db", "detail", ioerr.err, "path",
tmp_path);
}
return CBM_NOT_FOUND;
}
/* Open with cbm_store_open_path to auto-create missing indexes + FTS5 */
cbm_store_t *store = cbm_store_open_path(tmp_path);
if (!store) {
cbm_log_error("artifact.import", "err", "open_imported_db");
cbm_unlink(tmp_path);
return CBM_NOT_FOUND;
}
/* Deep integrity check — refuse corrupted artifacts. The shallow check
* only sanity-checks the projects table, so page-corrupted (torn)
* artifacts installed cleanly (#895); quick_check catches them. */
if (!cbm_store_check_integrity_deep(store)) {
cbm_log_error("artifact.import", "err", "integrity_check_failed");
cbm_store_close(store);
cbm_unlink(tmp_path);
return CBM_NOT_FOUND;
}
cbm_store_close(store);
/* Atomic rename to final path. Drop the DESTINATION's leftover
* -wal/-shm first: the import cleans the tmp file's sidecars, but a
* stale WAL next to the cache path would be replayed on top of the
* imported file at the next open (#897). */
cbm_remove_db_sidecars(cache_db_path);
if (rename(tmp_path, cache_db_path) != 0) {
cbm_log_error("artifact.import", "err", "rename_to_cache");
cbm_unlink(tmp_path);
return CBM_NOT_FOUND;
}
/* Clean up any stale WAL/SHM from the temp open */
char wal[CBM_SZ_4K];
char shm[CBM_SZ_4K];
snprintf(wal, sizeof(wal), "%s-wal", tmp_path);
snprintf(shm, sizeof(shm), "%s-shm", tmp_path);
cbm_unlink(wal);
cbm_unlink(shm);
cbm_log_info("artifact.import", "db", cache_db_path, "size_mb",
itoa_buf((int)((size_t)dlen / ART_BYTES_PER_MB)));
return 0;
}
/* ── Existence check ─────────────────────────────────────────────── */
bool cbm_artifact_exists(const char *repo_path) {
if (!repo_path) {
return false;
}
char zst_path[CBM_SZ_4K];
artifact_path(zst_path, sizeof(zst_path), repo_path, CBM_ARTIFACT_FILENAME);
struct stat st;
if (stat(zst_path, &st) != 0 || st.st_size == 0) {
return false;
}
/* Check schema version is compatible */
int version = read_metadata_version(repo_path);
return version >= 0 && version <= CBM_ARTIFACT_SCHEMA_VERSION;
}
/* ── Commit hash extraction ──────────────────────────────────────── */
char *cbm_artifact_commit(const char *repo_path) {
if (!repo_path) {
return NULL;
}
char meta_path[CBM_SZ_4K];
artifact_path(meta_path, sizeof(meta_path), repo_path, CBM_ARTIFACT_META);
size_t len = 0;
char *json = read_file_alloc(meta_path, &len);
if (!json) {
return NULL;
}
yyjson_doc *doc = yyjson_read(json, len, 0);
free(json);
if (!doc) {
return NULL;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *val = yyjson_obj_get(root, "commit");
char *result = NULL;
if (val) {
const char *s = yyjson_get_str(val);
if (s && s[0]) {
size_t slen = strlen(s);
result = malloc(slen + ART_NUL);
if (result) {
memcpy(result, s, slen + ART_NUL);
}
}
}
yyjson_doc_free(doc);
return result;
}
+64
View File
@@ -0,0 +1,64 @@
/*
* artifact.h — Persistent artifact export/import for team sharing.
*
* Exports the SQLite knowledge graph as a zstd-compressed artifact
* to .codebase-memory/graph.db.zst in the repository. Teammates
* can import the artifact to bootstrap their local index instead
* of running a full pipeline from scratch.
*/
#ifndef CBM_ARTIFACT_H
#define CBM_ARTIFACT_H
#include <stdbool.h>
/* Schema version — increment when DB schema changes (new tables/indexes).
* Import refuses artifacts with schema_version > current.
* v2: edges uniqueness widened to (source_id, target_id, type,
* local_name_gen) so sibling named imports coexist (#768) — old
* binaries cannot upsert against the widened constraint. */
#define CBM_ARTIFACT_SCHEMA_VERSION 2
#define CBM_ARTIFACT_FILENAME "graph.db.zst"
#define CBM_ARTIFACT_META "artifact.json"
#define CBM_ARTIFACT_DIR ".codebase-memory"
/* Export quality levels */
enum {
CBM_ARTIFACT_FAST = 0, /* zstd -3, no index stripping (watcher path) */
CBM_ARTIFACT_BEST = 1, /* zstd -9 + drop indexes + VACUUM INTO (explicit index) */
};
/* Export DB to .codebase-memory/graph.db.zst artifact.
* quality: CBM_ARTIFACT_FAST or CBM_ARTIFACT_BEST.
* Creates .codebase-memory/ dir, .gitattributes, and artifact.json.
* Returns 0 on success, -1 on error. */
int cbm_artifact_export(const char *db_path, const char *repo_path, const char *project_name,
int quality);
/* Get details for the most recent export failure on this thread.
* Returns NULL if no export error is recorded. */
const char *cbm_artifact_export_last_error(void);
/* Import artifact from .codebase-memory/graph.db.zst to cache_db_path.
* Decompresses, runs integrity check, recreates indexes.
* Returns 0 on success, -1 on error. */
int cbm_artifact_import(const char *repo_path, const char *cache_db_path);
/* Check if a compatible artifact exists in repo_path/.codebase-memory/.
* Returns true only if both graph.db.zst and artifact.json exist
* and schema_version is compatible. */
bool cbm_artifact_exists(const char *repo_path);
/* Get the git commit hash from artifact metadata. Caller must free().
* Returns NULL if artifact doesn't exist or has no commit field. */
char *cbm_artifact_commit(const char *repo_path);
/* Whether repo_path is safe to interpolate into a double-quoted `git -C "…"` shell
* command (as artifact.c does via cbm_popen). Rejects quote / backslash / shell
* substitution metacharacters (cbm_validate_shell_arg); on Windows also rejects the
* cmd.exe expansion metacharacters % ! ^. Spaces ARE allowed — double quotes handle
* them on both POSIX sh and cmd.exe (single quotes, which cmd.exe does not honor,
* were the pre-existing bug). Exposed so the shell-safety contract is unit-tested. */
bool cbm_artifact_repo_path_is_shell_safe(const char *repo_path);
#endif /* CBM_ARTIFACT_H */
+505
View File
@@ -0,0 +1,505 @@
/*
* fqn.c — Fully Qualified Name computation for graph nodes.
*
* Implements the FQN scheme: project.dir.parts.name
* Handles Python __init__.py, JS/TS index.{js,ts}, path separators.
*/
#include "pipeline/pipeline.h"
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/platform.h"
#include <stdbool.h>
#include <stddef.h> // NULL
#include <stdint.h> // uint32_t
#include <stdio.h>
#include <stdlib.h>
#include <string.h> // strdup
#ifdef _WIN32
#include <direct.h>
#include <io.h>
#endif
/* Maximum path segments in a FQN (CBM_SZ_256 slots total, -2 for project + name) */
#define FQN_MAX_PATH_SEGS 254
#define FQN_MAX_DIR_SEGS 255
/* Max bytes for a derived project name. The name becomes a filename component
* ("<cache>/<name>.db" and sidecars ".db-wal"/".db.corrupt"), so it must stay
* under the filesystem's 255-byte component limit. 200 leaves headroom for the
* longest sidecar suffix. #571 hex-encodes each non-ASCII byte to 2 chars, so a
* deep CJK path can triple past 255 and make the DB file un-openable (#624). */
#define FQN_MAX_NAME_LEN 200
/* ── Internal helpers ─────────────────────────────────────────────── */
/* Build a dot-joined string from segments. Returns heap-allocated string. */
static char *join_segments(const char **segments, int count) {
if (count == 0) {
return strdup("");
}
size_t total = 0;
for (int i = 0; i < count; i++) {
total += strlen(segments[i]);
if (i > 0) {
total++; /* dot separator */
}
}
char *result = malloc(total + SKIP_ONE);
if (!result) {
return NULL;
}
char *p = result;
for (int i = 0; i < count; i++) {
if (i > 0) {
*p++ = '.';
}
size_t len = strlen(segments[i]);
memcpy(p, segments[i], len);
p += len;
}
*p = '\0';
return result;
}
/* Strip file extension from the last path component. */
static void strip_file_extension(char *path) {
char *last_slash = strrchr(path, '/');
char *start = last_slash ? last_slash + SKIP_ONE : path;
char *ext = strrchr(start, '.');
if (ext) {
*ext = '\0';
}
}
/* Tokenize path by '/' into segments array. Returns number of segments added. */
static int tokenize_path(char *path, const char **segments, int max_segs) {
int count = 0;
if (path[0] == '\0') {
return 0;
}
char *tok = path;
while (tok && *tok && count < max_segs) {
char *slash = strchr(tok, '/');
if (slash) {
*slash = '\0';
}
if (tok[0] != '\0') {
segments[count++] = tok;
}
tok = slash ? slash + SKIP_ONE : NULL;
}
return count;
}
/* Strip __init__ (Python) / index (JS/TS) from the last segment when a
* symbol name is provided. Keeps it when no name is given to avoid QN
* collision with Folder nodes for the same directory. */
static void strip_init_or_index(const char **segments, int *seg_count, const char *name) {
if (*seg_count <= SKIP_ONE) {
return;
}
const char *last = segments[*seg_count - SKIP_ONE];
if (strcmp(last, "__init__") != 0 && strcmp(last, "index") != 0) {
return;
}
if (name && name[0] != '\0') {
(*seg_count)--;
}
}
/* ── Public API ──────────────────────────────────────────────────── */
char *cbm_pipeline_fqn_compute(const char *project, const char *rel_path, const char *name) {
if (!project) {
return strdup("");
}
char *path = strdup(rel_path ? rel_path : "");
cbm_normalize_path_sep(path);
strip_file_extension(path);
const char *segments[CBM_SZ_256];
int seg_count = 0;
segments[seg_count++] = project;
seg_count += tokenize_path(path, segments + seg_count, FQN_MAX_PATH_SEGS);
strip_init_or_index(segments, &seg_count, name);
if (name && name[0] != '\0') {
segments[seg_count++] = name;
}
char *result = join_segments(segments, seg_count);
free(path);
return result;
}
char *cbm_pipeline_fqn_module(const char *project, const char *rel_path) {
return cbm_pipeline_fqn_compute(project, rel_path, NULL);
}
char *cbm_pipeline_fqn_module_dir(const char *project, const char *rel_path, bool module_is_dir) {
if (!module_is_dir) {
/* Filename-stem module (default for all but Java/Go). */
return cbm_pipeline_fqn_module(project, rel_path);
}
/* Directory-module languages (Java package, Go package): the module is the
* CONTAINING DIRECTORY — strip the basename so a sibling file in the same
* dir shares the module QN. This MUST agree with the extraction-side
* cbm_fqn_module_source_lang() (internal/cbm/helpers.c) so the cross-file
* LSP caller_qn matches the def-node QN. */
const char *src = rel_path ? rel_path : "";
/* Strip the last path segment using either separator (the extraction side
* normalizes too); look for the rightmost '/' or '\\'. */
const char *last_fwd = strrchr(src, '/');
const char *last_bwd = strrchr(src, '\\');
const char *last_sep = last_fwd > last_bwd ? last_fwd : last_bwd;
if (!last_sep) {
/* Root file: empty directory → module is just the project. */
return cbm_pipeline_fqn_folder(project, "");
}
size_t dir_len = (size_t)(last_sep - src);
char *dir = (char *)malloc(dir_len + 1); /* +1 for NUL */
if (!dir) {
return NULL;
}
memcpy(dir, src, dir_len);
dir[dir_len] = '\0';
char *res = cbm_pipeline_fqn_folder(project, dir);
free(dir);
return res;
}
enum {
FQN_PATH_BUF = 1024,
FQN_SEP_LEN = 1, /* one byte for the '/' separator */
FQN_NUL_LEN = 1, /* one byte for the terminating NUL */
FQN_DOTDOT_LEN = 2,
FQN_MIN_PY_DOTS = 1, /* first leading dot is "current package", not a pop */
FQN_REL_KIND_NONE = 0,
FQN_REL_KIND_PYTHON = 1,
FQN_REL_KIND_JS = 2,
};
/* Append a single path segment to a mutable buffer that already holds a
* normalized slash-separated path. Adds a '/' separator when needed,
* returns false if the buffer would overflow. */
static bool path_append_segment(char *buf, size_t buf_size, const char *seg, size_t seg_len) {
size_t cur = strlen(buf);
size_t need = cur + (cur > 0 ? FQN_SEP_LEN : 0) + seg_len + FQN_NUL_LEN;
if (need > buf_size) {
return false;
}
if (cur > 0) {
buf[cur++] = '/';
}
memcpy(buf + cur, seg, seg_len);
buf[cur + seg_len] = '\0';
return true;
}
/* Pop the last segment from a mutable slash-separated path. */
static void path_pop_segment(char *buf) {
char *last = strrchr(buf, '/');
if (last) {
*last = '\0';
} else {
buf[0] = '\0';
}
}
/* Seed `buf` with the source file's directory (strip the basename) and
* normalize backslashes. */
static void seed_source_dir(char *buf, size_t buf_size, const char *source_rel) {
snprintf(buf, buf_size, "%s", source_rel ? source_rel : "");
for (char *p = buf; *p; p++) {
if (*p == '\\') {
*p = '/';
}
}
char *last = strrchr(buf, '/');
if (last) {
*last = '\0';
} else {
buf[0] = '\0';
}
}
/* Detect the flavor of relative import based on the leading characters.
* Returns 1 for Python dotted form (e.g. ".foo" or "..bar.baz"),
* 2 for JS/TS slash form (e.g. "./foo" or "../bar/baz"),
* 0 for anything not relative (caller should skip). */
static int classify_relative_import(const char *module_path) {
if (!module_path || module_path[0] != '.') {
return FQN_REL_KIND_NONE;
}
bool has_slash = strchr(module_path, '/') != NULL;
bool js_like = module_path[FQN_SEP_LEN] == '/' ||
(module_path[FQN_SEP_LEN] == '.' && module_path[FQN_DOTDOT_LEN] == '/');
if (has_slash || js_like) {
return FQN_REL_KIND_JS;
}
return FQN_REL_KIND_PYTHON;
}
/* Python relative import: ".foo", "..bar.baz" → resolve against source dir. */
static char *resolve_python_relative(char *buf, size_t buf_size, const char *module_path) {
const char *p = module_path;
int dot_count = 0;
while (*p == '.') {
dot_count++;
p++;
}
for (int i = FQN_MIN_PY_DOTS; i < dot_count; i++) {
path_pop_segment(buf);
}
while (*p) {
const char *seg_start = p;
while (*p && *p != '.') {
p++;
}
size_t seg_len = (size_t)(p - seg_start);
if (seg_len > 0 && !path_append_segment(buf, buf_size, seg_start, seg_len)) {
return NULL;
}
if (*p == '.') {
p++;
}
}
return strdup(buf);
}
/* Strip a trailing file extension from a segment (e.g. "helpers.ts" → "helpers").
* Returns the new segment length. */
static size_t strip_ext(const char *seg_start, size_t seg_len) {
const char *seg_end = seg_start + seg_len;
const char *dot = NULL;
for (const char *d = seg_end - FQN_SEP_LEN; d >= seg_start; d--) {
if (*d == '.') {
dot = d;
break;
}
}
if (dot && dot > seg_start) {
return (size_t)(dot - seg_start);
}
return seg_len;
}
/* JS/TS relative import: "./foo", "../bar/baz" → resolve against source dir. */
static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_path) {
const char *p = module_path;
while (*p) {
while (*p == '/') {
p++;
}
if (!*p) {
break;
}
const char *seg_start = p;
while (*p && *p != '/') {
p++;
}
size_t seg_len = (size_t)(p - seg_start);
if (seg_len == FQN_SEP_LEN && seg_start[0] == '.') {
continue;
}
if (seg_len == FQN_DOTDOT_LEN && seg_start[0] == '.' && seg_start[FQN_SEP_LEN] == '.') {
path_pop_segment(buf);
continue;
}
if (*p == '\0') {
seg_len = strip_ext(seg_start, seg_len);
}
if (seg_len > 0 && !path_append_segment(buf, buf_size, seg_start, seg_len)) {
return NULL;
}
}
return strdup(buf);
}
char *cbm_pipeline_resolve_relative_import(const char *source_rel, const char *module_path) {
int kind = classify_relative_import(module_path);
if (kind == FQN_REL_KIND_NONE) {
return NULL;
}
char buf[FQN_PATH_BUF];
seed_source_dir(buf, sizeof(buf), source_rel);
if (kind == FQN_REL_KIND_PYTHON) {
return resolve_python_relative(buf, sizeof(buf), module_path);
}
return resolve_js_relative(buf, sizeof(buf), module_path);
}
char *cbm_pipeline_fqn_folder(const char *project, const char *rel_dir) {
if (!project) {
return strdup("");
}
/* Work on mutable copy */
char *dir = strdup(rel_dir ? rel_dir : "");
cbm_normalize_path_sep(dir);
const char *segments[CBM_SZ_256];
int seg_count = 0;
segments[seg_count++] = project;
if (dir[0] != '\0') {
char *tok = dir;
while (tok && *tok && seg_count < FQN_MAX_DIR_SEGS) {
char *slash = strchr(tok, '/');
if (slash) {
*slash = '\0';
}
if (tok[0] != '\0') {
segments[seg_count++] = tok;
}
tok = slash ? slash + SKIP_ONE : NULL;
}
}
char *result = join_segments(segments, seg_count);
free(dir);
return result;
}
/* Bound a derived project name to FQN_MAX_NAME_LEN bytes so "<cache>/<name>.db"
* stays within the filesystem's 255-byte filename-component limit (#624). Names
* within the cap are returned UNCHANGED (no drift). Longer names keep their first
* (CAP-9) bytes and get a "-XXXXXXXX" FNV-1a hash of the FULL name appended, so
* two long paths that share a prefix but differ later still map to distinct
* names. The suffix ends in a hex digit, so the result stays validator-safe. */
static char *fqn_bound_name_len(char *name) {
if (!name) {
return name;
}
size_t n = strlen(name);
if (n <= FQN_MAX_NAME_LEN) {
return name; /* within cap → unchanged, no drift */
}
uint32_t h = 2166136261u; /* FNV-1a offset basis over the FULL name */
for (size_t i = 0; i < n; i++) {
h ^= (unsigned char)name[i];
h *= 16777619u;
}
/* Keep first (CAP-9) bytes + "-" + 8 hex = CAP total. The buffer holds n+1
* bytes and n > CAP, so writing 9 chars + NUL at offset (CAP-9) fits. */
snprintf(name + (FQN_MAX_NAME_LEN - 9), 10, "-%08x", h);
return name;
}
static bool path_is_root_syntax(const char *path) {
if (!path || !path[0]) {
return false;
}
for (const char *p = path; *p; p++) {
if (*p != '/' && *p != '\\' && *p != ':') {
return false;
}
}
return true;
}
char *cbm_project_name_from_path(const char *abs_path) {
if (!abs_path || !abs_path[0]) {
return strdup("root");
}
if (path_is_root_syntax(abs_path)) {
return strdup("root");
}
char real[CBM_SZ_4K];
const char *name_path = abs_path;
/* Wide-path canonicalization — the ANSI _access/_fullpath pair corrupted
* CJK paths on CJK-locale Windows (#973). */
if (cbm_canonical_path(abs_path, real, sizeof(real))) {
cbm_normalize_path_sep(real);
name_path = real;
}
/* Work on mutable copy */
char *path = strdup(name_path);
if (!path) {
return NULL;
}
size_t len = strlen(path);
/* Normalize path separators */
cbm_normalize_path_sep(path);
/* Map every character that is unsafe for portable project DB names. We
* keep derived names in [A-Za-z0-9._-], so anything else — path
* separators, ':', spaces, '@', '+', … — must be normalized here.
* Otherwise a repo like
* "/home/u/my project" yields the name "home-u-my project": indexing
* creates the DB and it shows in list_projects, but resolve_store rejects
* the space and reports project-not-found (#349).
*
* Non-ASCII bytes (UTF-8 of CJK and other scripts, all >= 0x80) are NOT
* dropped to '-' — that silently erased whole path segments and produced
* unrecognizable / colliding names (#571). Instead each non-ASCII byte is
* transliterated to its two lowercase hex digits, which use only [0-9a-f]
* and therefore stay validator-safe while preserving the segment. */
static const char hex_digits[] = "0123456789abcdef";
char *mapped = malloc(len * 2 + 1); /* worst case: every byte → 2 hex chars */
if (!mapped) {
free(path);
return strdup("root");
}
size_t mlen = 0;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)path[i];
bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
if (safe) {
mapped[mlen++] = (char)c;
} else if (c >= 0x80) {
mapped[mlen++] = hex_digits[(c >> 4) & 0xF];
mapped[mlen++] = hex_digits[c & 0xF];
} else {
mapped[mlen++] = '-';
}
}
mapped[mlen] = '\0';
free(path);
path = mapped;
len = mlen;
/* Collapse consecutive dashes, and consecutive dots (the validator also
* rejects any ".." sequence). */
char *dst = path;
char prev = 0;
for (size_t i = 0; i < len; i++) {
if ((path[i] == '-' && prev == '-') || (path[i] == '.' && prev == '.')) {
continue;
}
*dst++ = path[i];
prev = path[i];
}
*dst = '\0';
/* Trim leading dashes and dots (the validator rejects a leading dot). */
char *start = path;
while (*start == '-' || *start == '.') {
start++;
}
/* Trim trailing dashes */
size_t slen = strlen(start);
while (slen > 0 && start[slen - SKIP_ONE] == '-') {
start[--slen] = '\0';
}
if (*start == '\0') {
free(path);
return strdup("root");
}
char *result = strdup(start);
free(path);
if (result) {
result = fqn_bound_name_len(result); /* #624: cap filename-component length */
}
return result;
}
+299
View File
@@ -0,0 +1,299 @@
/*
* lsp_resolve.h — Shared LSP-override resolver for the call-edge pipeline.
*
* Both pipeline paths (sequential cbm_pipeline_pass_calls and parallel
* cbm_parallel_extract → resolve_file_calls) need to look up an
* LSP-resolved call for a given (caller, callee) pair before falling back
* to the registry's name-based resolver. Before this header existed, each
* pipeline carried its own copy of that lookup with divergent confidence
* floors and slightly different match semantics — most production
* indexing went through the parallel path with a 0.5 floor while the
* sequential path used 0.6, so the same project produced different
* CALLS-edge attributions depending on which pipeline mode kicked in.
*
* Centralising the lookup here means both pipelines admit exactly the
* same set of LSP overrides. Each pipeline still owns its own edge
* emission (sequential uses emit_classified_edge, parallel uses
* emit_service_edge) — this header only does the matching.
*
* Inline-only: no .c file needed.
*/
#ifndef CBM_PIPELINE_LSP_RESOLVE_H
#define CBM_PIPELINE_LSP_RESOLVE_H
#include "cbm.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/constants.h"
#include <stdio.h>
#include <string.h>
/* Confidence floor below which LSP-resolved calls are ignored and the
* registry resolver is consulted instead. Locked at 0.6 per the v1
* Python-LSP integration plan; revisit when telemetry justifies a knob.
* Applies to every language whose LSP populates result->resolved_calls
* (Go, C/C++, Python, PHP). */
#define CBM_LSP_CONFIDENCE_FLOOR 0.6f
/* Bare last segment of a (possibly qualified) name, splitting on the LAST
* member/scope separator. C++ textual callees carry `::` (Class::method,
* Ns::f) and `->` (p->run), while the LSP records dotted internal QNs
* (Class.method). Splitting only on '.' (strrchr) leaves `Math::square`
* and `p->run` intact, so they never match the LSP's `square`/`run` short
* name and the type-aware strategy is silently dropped to the textual
* registry. Treat '.', ':' and '>' as terminal separators so the bare
* method name is recovered on BOTH the QN side (dotted, occasionally `::`
* for template/alias scopes) and the textual side (`.`/`::`/`->`). Other
* languages' callee names contain none of `::`/`->`, so this is a no-op
* for them. */
static inline const char *cbm_lsp_bare_segment(const char *name) {
if (!name) {
return name;
}
const char *seg = name;
for (const char *p = name; *p; p++) {
/* '.' (dotted QN / Java-style member) and ':' (C++ `::`, last colon
* wins) are member/scope separators. '>' is only a separator when it
* closes the `->` arrow (preceded by '-'); a bare '>' closes a template
* argument list ("identity<int>") and must NOT split, else the segment
* would be the empty string after the trailing '>'. */
if (*p == '.' || *p == ':' || (*p == '>' && p != name && p[-1] == '-')) {
seg = p + SKIP_ONE;
}
}
return seg;
}
/* Tail helper: return the start of the final two dot-separated segments
* ("Class.method") or NULL when the QN is too short. */
static inline const char *cbm_pipeline_qn_class_method_tail(const char *qn) {
if (!qn) {
return NULL;
}
const char *last = strrchr(qn, '.');
if (!last || last == qn) {
return NULL;
}
const char *second = last;
while (second > qn) {
second--;
if (*second == '.') {
if (second == qn) {
return qn;
}
return second + 1;
}
}
return qn;
}
static inline const char *cbm_pipeline_call_callee_leaf(const char *callee_name) {
return cbm_lsp_bare_segment(callee_name);
}
/* Gate for the unique-`Class.method`-tail fallbacks below. Tail-matching by
* leaf is safe where class-per-file package semantics hold — the JVM
* languages (Java/Kotlin): the declared `package` is ground truth, a class
* name is unique within a package, and mixed Gradle/Maven source roots
* (`src/main/java` + `src/main/kotlin`) legitimately produce path-derived
* module QNs that disagree with the package-shaped QNs the LSP emits, so
* the tail is the only reliable join key. In other languages the same-name
* guarantee does not exist (Python/TS re-export shims, Go internal clones,
* C++ template instantiations), and a single wrong-module coincidence
* would fabricate a CALLS edge — so the fallbacks stay off there. */
static inline bool cbm_pipeline_lsp_allow_tail_match(CBMLanguage lang) {
return lang == CBM_LANG_JAVA || lang == CBM_LANG_KOTLIN;
}
static inline int cbm_pipeline_qn_class_method_tail_eq(const char *qn, const char *tail) {
const char *qt = cbm_pipeline_qn_class_method_tail(qn);
return qt && tail && strcmp(qt, tail) == 0;
}
/* Look up the highest-confidence LSP-resolved call entry whose caller QN
* matches the textual call's enclosing function and whose callee QN
* short-name matches the textual callee. Returns a pointer into `arr`
* or NULL if no qualifying entry exists.
*
* Match rule:
* 1. exact caller_qn + callee short-name match wins first;
* 2. if no exact caller match exists AND allow_tail_match is set
* (JVM callers only, see cbm_pipeline_lsp_allow_tail_match), a
* unique Class.method tail match between rc->caller_qn and
* call->enclosing_func_qn may win;
* 3. ambiguous tails return NULL so the registry fallback stays in
* control.
*
* Qualified static callees (e.g. Perl `Pkg::sub`) are reduced to their
* bare last segment by cbm_lsp_bare_segment before matching.
*
* The pointer returned aliases into `arr` and stays valid as long as the
* underlying CBMFileResult is alive. */
static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution(
const CBMResolvedCallArray *arr, const CBMCall *call, bool allow_tail_match) {
if (!arr || arr->count == 0 || !call) {
return NULL;
}
if (!call->enclosing_func_qn || !call->callee_name) {
return NULL;
}
const CBMResolvedCall *best_exact = NULL;
for (int i = 0; i < arr->count; i++) {
const CBMResolvedCall *rc = &arr->items[i];
if (!rc->caller_qn || !rc->callee_qn) {
continue;
}
if (rc->confidence < CBM_LSP_CONFIDENCE_FLOOR) {
continue;
}
if (strcmp(rc->caller_qn, call->enclosing_func_qn) != 0) {
continue;
}
const char *short_name = cbm_lsp_bare_segment(rc->callee_qn);
/* The call's callee_name is receiver-qualified for method/qualified
* calls ("c.inc", "A.Helper", "Math::square", "p->run"); the LSP
* records the resolved class-qualified callee_qn ("Class.inc"). Compare
* the bare last segment on BOTH sides so method-dispatch resolutions
* join — the LSP already did the receiver->type resolution, and matching
* the full "c.inc" against "inc" would always miss, silently dropping the
* type-aware LSP strategy to the weaker textual registry. Free-function
* calls (bare callee_name) are unaffected. */
const char *call_short = cbm_lsp_bare_segment(call->callee_name);
if (strcmp(short_name, call_short) != 0) {
/* Indirect/implicit resolution: the textual callee differs from the
* resolved callee_qn's short name. A function-pointer / DLL call's
* callee is the pointer name (`fp`); a C++ destructor's only textual
* anchor is the deleted operand (`p`, vs. the `T.~T` callee QN). In
* both the LSP stashed the original textual name in `reason`. Match
* the call site on that name, gated to those strategies so `reason`
* is never misread as an unresolved-call diagnostic. */
if (!(rc->reason && rc->strategy &&
(strcmp(rc->strategy, "lsp_func_ptr") == 0 ||
strcmp(rc->strategy, "lsp_dll_resolve") == 0 ||
strcmp(rc->strategy, "lsp_method_ref_ctor") == 0 ||
strcmp(rc->strategy, "lsp_method_ref_ctor_synth") == 0 ||
strcmp(rc->strategy, "lsp_dict_dispatch") == 0 ||
strcmp(rc->strategy, "lsp_destructor") == 0 ||
strcmp(rc->strategy, "php_method_dynamic") == 0) &&
strcmp(cbm_lsp_bare_segment(rc->reason), call_short) == 0)) {
continue;
}
}
if (!best_exact || rc->confidence > best_exact->confidence) {
best_exact = rc;
}
}
if (best_exact) {
return best_exact;
}
if (!allow_tail_match) {
return NULL;
}
const char *call_tail = cbm_pipeline_qn_class_method_tail(call->enclosing_func_qn);
if (!call_tail) {
return NULL;
}
const CBMResolvedCall *best_tail = NULL;
for (int i = 0; i < arr->count; i++) {
const CBMResolvedCall *rc = &arr->items[i];
if (!rc->caller_qn || !rc->callee_qn) {
continue;
}
if (rc->confidence < CBM_LSP_CONFIDENCE_FLOOR) {
continue;
}
const char *short_name = strrchr(rc->callee_qn, '.');
short_name = short_name ? short_name + SKIP_ONE : rc->callee_qn;
const char *call_leaf = cbm_pipeline_call_callee_leaf(call->callee_name);
if (!call_leaf || strcmp(short_name, call_leaf) != 0) {
continue;
}
if (!cbm_pipeline_qn_class_method_tail_eq(rc->caller_qn, call_tail)) {
continue;
}
if (best_tail) {
return NULL;
}
best_tail = rc;
}
return best_tail;
}
/* Resolve an LSP-emitted callee_qn to a graph-buffer node.
*
* Per-file LSPs sometimes emit `callee_qn` as the raw package-shaped
* import path the source code uses rather than the project-qualified QN
* the gbuf actually stores. The fallback rule is:
* 1. try the LSP-emitted QN as-is;
* 2. retry with `<project>.<callee_qn>` when needed;
* 3. if both fail AND allow_tail_match is set (JVM callers only, see
* cbm_pipeline_lsp_allow_tail_match), use the exact node-name index
* to narrow candidates by short method name and accept exactly one
* Function/Method whose qualified_name has the same Class.method
* tail.
*
* Returns the matching node, or NULL if neither lookup hits. */
static inline const cbm_gbuf_node_t *cbm_pipeline_lsp_target_node(const cbm_gbuf_t *gbuf,
const char *project_name,
const char *callee_qn,
bool allow_tail_match) {
if (!gbuf || !callee_qn) {
return NULL;
}
const cbm_gbuf_node_t *direct = cbm_gbuf_find_by_qn(gbuf, callee_qn);
if (direct) {
return direct;
}
if (project_name && project_name[0]) {
size_t proj_len = strlen(project_name);
if (!(strncmp(callee_qn, project_name, proj_len) == 0 && callee_qn[proj_len] == '.')) {
char buf[CBM_SZ_1K];
int written = snprintf(buf, sizeof(buf), "%s.%s", project_name, callee_qn);
if (written > 0 && (size_t)written < sizeof(buf)) {
const cbm_gbuf_node_t *prefixed = cbm_gbuf_find_by_qn(gbuf, buf);
if (prefixed) {
return prefixed;
}
}
}
}
if (!allow_tail_match) {
return NULL;
}
const char *short_name = strrchr(callee_qn, '.');
short_name = short_name ? short_name + SKIP_ONE : callee_qn;
const char *callee_tail = cbm_pipeline_qn_class_method_tail(callee_qn);
if (!callee_tail) {
return NULL;
}
const cbm_gbuf_node_t **hits = NULL;
int hit_count = 0;
if (cbm_gbuf_find_by_name(gbuf, short_name, &hits, &hit_count) != 0 || hit_count == 0) {
return NULL;
}
const cbm_gbuf_node_t *match = NULL;
for (int i = 0; i < hit_count; i++) {
const cbm_gbuf_node_t *cand = hits[i];
if (!cand || !cand->label || !cand->qualified_name) {
continue;
}
if (strcmp(cand->label, "Function") != 0 && strcmp(cand->label, "Method") != 0) {
continue;
}
if (!cbm_pipeline_qn_class_method_tail_eq(cand->qualified_name, callee_tail)) {
continue;
}
if (match) {
return NULL;
}
match = cand;
}
return match;
}
#endif /* CBM_PIPELINE_LSP_RESOLVE_H */
+858
View File
@@ -0,0 +1,858 @@
/*
* pass_calls.c — Resolve function/method calls into CALLS edges.
*
* For each discovered file:
* 1. Re-extract calls (cbm_extract_file)
* 2. Build per-file import map from IMPORTS edges in graph buffer
* 3. Resolve each call via registry (import_map → same_module → unique → suffix)
* 4. Create CALLS edges in graph buffer with confidence/strategy properties
*
* Depends on: pass_definitions having populated the registry and graph buffer
*/
#include "foundation/constants.h"
enum { PC_RING = 4, PC_RING_MASK = 3, PC_SIG_SCAN = 15, PC_REGEX_GRP = 2 };
/* 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
#include "pipeline/pipeline.h"
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "pipeline/lsp_resolve.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "foundation/str_util.h"
#include "cbm.h"
#include "service_patterns.h"
#include "foundation/compat_regex.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* True for languages whose module QN derives from the CONTAINING DIRECTORY
* (Java/Go package). MUST match cbm_lang_module_is_dir() (internal/cbm/helpers.c)
* so same-module callee resolution keys against the directory-based def-node
* QNs in the registry. */
static bool pc_module_is_dir(CBMLanguage lang) {
return lang == CBM_LANG_JAVA || lang == CBM_LANG_GO;
}
/* Read entire file into heap-allocated buffer. Caller must free(). */
static char *read_file(const char *path, int *out_len) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */
(void)fclose(f);
return NULL;
}
/* +pad: tree-sitter lexer lookahead reads past EOF; keep it in-bounds */
enum { CBM_TS_LOOKAHEAD_PAD = 16 };
char *buf = malloc((size_t)size + CBM_TS_LOOKAHEAD_PAD);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t nread = fread(buf, SKIP_ONE, size, f);
(void)fclose(f);
if (nread > (size_t)size) {
nread = (size_t)size;
}
memset(buf + nread, 0, CBM_TS_LOOKAHEAD_PAD);
*out_len = (int)nread;
return buf;
}
/* Format int for logging. Thread-safe via TLS. */
static const char *itoa_log(int val) {
static CBM_TLS char bufs[PC_RING][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & PC_RING_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Build per-file import map from cached extraction result or graph buffer edges.
* Returns parallel arrays of (local_name, module_qn) pairs. Caller frees. */
/* Parse "local_name":"value" from JSON properties string. Returns strdup'd key or NULL. */
static char *extract_local_name_from_json(const char *props_json) {
if (!props_json) {
return NULL;
}
const char *start = strstr(props_json, "\"local_name\":\"");
if (!start) {
return NULL;
}
start += strlen("\"local_name\":\"");
const char *end = strchr(start, '"');
if (!end || end <= start) {
return NULL;
}
return cbm_strndup(start, end - start);
}
static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path,
const CBMFileResult *result, const char ***out_keys,
const char ***out_vals, int *out_count) {
*out_keys = NULL;
*out_vals = NULL;
*out_count = 0;
/* Fast path: build from cached extraction result (no JSON parsing) */
if (result && result->imports.count > 0) {
const char **keys = calloc((size_t)result->imports.count, sizeof(const char *));
const char **vals = calloc((size_t)result->imports.count, sizeof(const char *));
int count = 0;
for (int i = 0; i < result->imports.count; i++) {
const CBMImport *imp = &result->imports.items[i];
if (!imp->local_name || !imp->local_name[0] || !imp->module_path) {
continue;
}
char *target_qn = cbm_pipeline_fqn_module(ctx->project_name, imp->module_path);
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
free(target_qn);
if (!target) {
continue;
}
keys[count] = strdup(imp->local_name);
vals[count] = target->qualified_name; /* borrowed from gbuf */
count++;
}
*out_keys = keys;
*out_vals = vals;
*out_count = count;
return 0;
}
/* Slow path: scan graph buffer IMPORTS edges + parse JSON properties */
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
free(file_qn);
if (!file_node) {
return 0;
}
const cbm_gbuf_edge_t **edges = NULL;
int edge_count = 0;
int rc = cbm_gbuf_find_edges_by_source_type(ctx->gbuf, file_node->id, "IMPORTS", &edges,
&edge_count);
if (rc != 0 || edge_count == 0) {
return 0;
}
const char **keys = calloc(edge_count, sizeof(const char *));
const char **vals = calloc(edge_count, sizeof(const char *));
int count = 0;
for (int i = 0; i < edge_count; i++) {
const cbm_gbuf_edge_t *e = edges[i];
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(ctx->gbuf, e->target_id);
if (!target) {
continue;
}
char *key = extract_local_name_from_json(e->properties_json);
if (key) {
keys[count] = key;
vals[count] = target->qualified_name;
count++;
}
}
*out_keys = keys;
*out_vals = vals;
*out_count = count;
return 0;
}
static void free_import_map(const char **keys, const char **vals, int count) {
if (keys) {
for (int i = 0; i < count; i++) {
free((void *)keys[i]);
}
free((void *)keys);
}
if (vals) {
free((void *)vals);
}
}
/* Handle a route registration call: create Route node + HANDLES edge. */
static void 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 *method = cbm_service_pattern_route_method(call->callee_name);
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY",
cbm_route_canon_path(call->first_string_arg, cpath, sizeof(cpath)));
char route_props[CBM_SZ_256];
snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method ? method : "ANY");
int64_t route_id = cbm_gbuf_upsert_node(ctx->gbuf, "Route", call->first_string_arg, route_qn,
"", 0, 0, route_props);
char esc_cn[CBM_SZ_256]; /* sliced source text: escape quotes/newlines */
char esc_fa[CBM_SZ_256];
cbm_json_escape(esc_cn, sizeof(esc_cn), call->callee_name);
cbm_json_escape(esc_fa, sizeof(esc_fa), call->first_string_arg);
char props[CBM_SZ_512];
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);
if (call->second_arg_name != NULL && call->second_arg_name[0] != '\0') {
cbm_resolution_t hres = cbm_registry_resolve(ctx->registry, call->second_arg_name,
module_qn, imp_keys, imp_vals, imp_count);
if (hres.qualified_name != NULL && hres.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *handler = cbm_gbuf_find_by_qn(ctx->gbuf, hres.qualified_name);
if (handler != NULL) {
char hprops[CBM_SZ_1K]; /* must exceed escaped value + wrapper or snprintf cuts the
closing brace */
char esc_h[CBM_SZ_512];
cbm_json_escape(esc_h, sizeof(esc_h), hres.qualified_name);
snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", esc_h);
cbm_gbuf_insert_edge(ctx->gbuf, handler->id, route_id, "HANDLES", hprops);
}
}
}
}
/* Emit an HTTP/async route edge for a service call. */
/* Build route QN and upsert Route node for HTTP/async edge. */
static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, cbm_svc_kind_t svc,
const char *method, const char *broker) {
char route_qn[CBM_ROUTE_QN_SIZE];
const char *prefix;
char cpath[CBM_SZ_256];
const char *qpath = url;
if (svc == CBM_SVC_HTTP) {
prefix = method ? method : "ANY";
qpath = cbm_route_canon_path(url, cpath, sizeof(cpath));
} else {
prefix = broker ? broker : "async";
}
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath);
/* Valid-JSON route properties, byte-identical to the parallel path's
* build_service_route. The old code stored the RAW method/broker string
* (literally `bullmq`), which broke json_extract, the edges generated
* columns, and PRAGMA quick_check on every such cache (#898). */
char route_props[CBM_SZ_256];
if (method) {
snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method);
} else if (broker) {
snprintf(route_props, sizeof(route_props), "{\"broker\":\"%s\"}", broker);
} else {
snprintf(route_props, sizeof(route_props), "{}");
}
return cbm_gbuf_upsert_node(ctx->gbuf, "Route", url, route_qn, "", 0, 0, route_props);
}
/* Insert an edge, splicing the call-site line (,"line":N) in before the closing
* brace when one was captured. Mirrors finalize_and_emit() on the parallel path
* so CALLS edges carry their source line regardless of resolution path. Restricted
* to CALLS: route/config edge props feed full-only predump passes
* (create_route_nodes/create_data_flows), so altering them desyncs full vs
* incremental indexing. */
/* Append a ,"args":[{"i":0,"e":"<expr>","v":"<value>"},...] field onto a CALLS
* edge's JSON props (the props buffer ends in '}'). The sequential pass omitted
* this, so data_flow mode had no argument expressions to surface for small
* (< 50 file) repos that take the sequential path (#514). Mirrors the parallel
* path's append_args_json shape so both pipelines agree. */
static void calls_append_args(char *props, size_t cap, const CBMCall *call) {
if (!call || call->arg_count <= 0) {
return;
}
size_t len = strlen(props);
if (len < SKIP_ONE || props[len - SKIP_ONE] != '}') {
return;
}
/* Overwrite the trailing '}' and rebuild it after the args array. */
size_t pos = len - SKIP_ONE;
int n = snprintf(props + pos, cap - pos, ",\"args\":[");
if (n <= 0 || (size_t)n >= cap - pos) {
return;
}
pos += (size_t)n;
for (int i = 0; i < call->arg_count; i++) {
const CBMCallArg *a = &call->args[i];
char esc_e[CBM_SZ_256];
cbm_json_escape(esc_e, sizeof(esc_e), a->expr ? a->expr : "");
char one[CBM_SZ_512];
if (a->value) {
char esc_v[CBM_SZ_256];
cbm_json_escape(esc_v, sizeof(esc_v), a->value);
n = snprintf(one, sizeof(one), "%s{\"i\":%d,\"e\":\"%s\",\"v\":\"%s\"}",
i > 0 ? "," : "", a->index, esc_e, esc_v);
} else {
n = snprintf(one, sizeof(one), "%s{\"i\":%d,\"e\":\"%s\"}", i > 0 ? "," : "", a->index,
esc_e);
}
if (n <= 0 || (size_t)n >= cap - pos - PAIR_LEN) {
break; /* not enough room — close the array with what fits */
}
memcpy(props + pos, one, (size_t)n);
pos += (size_t)n;
}
if (pos + PAIR_LEN < cap) {
props[pos++] = ']';
props[pos++] = '}';
props[pos] = '\0';
}
}
static void 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);
if (len >= SKIP_ONE && props[len - SKIP_ONE] == '}' && len + CBM_SZ_32 < cap) {
snprintf(props + len - SKIP_ONE, cap - (len - SKIP_ONE), ",\"line\":%d}",
call->start_line);
}
}
if (call && strcmp(type, "CALLS") == 0) {
calls_append_args(props, cap, call);
}
cbm_gbuf_insert_edge(gbuf, src, tgt, type, props);
}
static void 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) {
const char *url_or_topic = call->first_string_arg;
bool is_url = (url_or_topic && url_or_topic[0] != '\0' &&
(url_or_topic[0] == '/' || strstr(url_or_topic, "://") != NULL));
bool is_topic = (url_or_topic && url_or_topic[0] != '\0' && svc == CBM_SVC_ASYNC &&
strlen(url_or_topic) > PAIR_LEN);
if (!is_url && !is_topic) {
/* No URL/topic → this is not a real service call; the svc kind was a
* substring coincidence in the resolved QN (e.g. "SalesforceRestClient"
* matches the "RestClient" HTTP lib). Emit a plain CALLS edge — unless a
* weak TS/JS member-call match should be suppressed (#592/#606). */
if (suppress_plain_calls) {
return;
}
char esc_callee[CBM_SZ_256];
cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name);
char props[CBM_SZ_512];
snprintf(props, sizeof(props),
"{\"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;
}
const char *edge_type = (svc == CBM_SVC_HTTP) ? "HTTP_CALLS" : "ASYNC_CALLS";
const char *method =
(svc == CBM_SVC_HTTP) ? cbm_service_pattern_http_method(call->callee_name) : NULL;
const char *broker =
(svc == CBM_SVC_ASYNC) ? cbm_service_pattern_broker(res->qualified_name) : NULL;
int64_t route_id = create_svc_route_node(ctx, url_or_topic, svc, method, broker);
char esc_callee[CBM_SZ_256];
char esc_url[CBM_SZ_256];
cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name);
cbm_json_escape(esc_url, sizeof(esc_url), url_or_topic);
/* Incremental build mirroring the parallel path's
* emit_http_async_service_edge: the old single format string closed the
* method value's quote but not the broker's, emitting
* "broker":"bullmq} on EVERY brokered ASYNC_CALLS edge — and its fixup
* only handled truncation, which never fires in the normal case (#898). */
char props[CBM_SZ_512];
int n = snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"url_path\":\"%s\"", esc_callee,
esc_url);
if (method && n > 0 && (size_t)n < sizeof(props)) {
n += snprintf(props + n, sizeof(props) - (size_t)n, ",\"method\":\"%s\"", method);
}
if (broker && n > 0 && (size_t)n < sizeof(props)) {
n += snprintf(props + n, sizeof(props) - (size_t)n, ",\"broker\":\"%s\"", broker);
}
if (n > 0 && (size_t)n < sizeof(props) - 1) {
props[n] = '}';
props[n + 1] = '\0';
}
calls_emit_edge(ctx->gbuf, source->id, route_id, edge_type, props, sizeof(props), call);
}
/* Classify a resolved call and emit the appropriate edge. */
/* When suppress_plain_calls is true (a TS/JS/TSX weak short-name member-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,
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,
bool suppress_plain_calls) {
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);
return;
}
if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) {
emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls);
return;
}
if (svc == CBM_SVC_CONFIG) {
char esc_c[CBM_SZ_256];
char esc_k[CBM_SZ_256];
cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name);
cbm_json_escape(esc_k, sizeof(esc_k), call->first_string_arg ? call->first_string_arg : "");
char props[CBM_SZ_512];
snprintf(props, sizeof(props), "{\"callee\":\"%s\",\"key\":\"%s\",\"confidence\":%.2f}",
esc_c, esc_k, res->confidence);
calls_emit_edge(ctx->gbuf, source->id, target->id, "CONFIGURES", props, sizeof(props),
call);
return;
}
if (suppress_plain_calls) {
return; /* 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);
char props[CBM_SZ_512];
snprintf(props, sizeof(props),
"{\"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);
}
/* Find source node for a call: enclosing function or file node. */
static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const char *rel,
const char *enclosing_qn) {
const cbm_gbuf_node_t *src = NULL;
if (enclosing_qn) {
src = cbm_gbuf_find_by_qn(ctx->gbuf, enclosing_qn);
/* A class-level call in a directory-module language carries the
* DIRECTORY module QN, which hits the shared Folder/Project node —
* attribute to this file's File node instead (#787). */
if (cbm_pipeline_node_is_dir_container(src)) {
src = NULL;
}
}
if (!src) {
char *fqn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
src = cbm_gbuf_find_by_qn(ctx->gbuf, fqn);
free(fqn);
}
return src;
}
/* 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 CBMResolvedCallArray *lsp_calls, const char *rel,
const char *module_qn, const char **imp_keys, const char **imp_vals,
int imp_count, CBMLanguage lang) {
const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn);
if (!source_node) {
return 0;
}
/* LSP-resolved calls take precedence over registry-textual matching.
* Unique-tail fallbacks are JVM-only (see cbm_pipeline_lsp_allow_tail_match). */
bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang);
const CBMResolvedCall *lsp = cbm_pipeline_find_lsp_resolution(lsp_calls, call, allow_tail);
if (lsp) {
const cbm_gbuf_node_t *target_node =
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;
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
imp_vals, imp_count, false);
return SKIP_ONE;
}
}
/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the service
* signal lives in the callee_name. The registry can mis-resolve such a call
* to a spurious builtin short-name match (e.g. `requests.get` ->
* `builtins.dict.get` via "get", strategy unique_name), which is non-empty
* and not an HTTP pattern, so BOTH the empty-resolution and resolved-QN
* service checks below miss it and the call is dropped. Detect it on the
* callee_name FIRST so the HTTP_CALLS/ASYNC_CALLS edge is emitted regardless
* (target is a synthesized route node, not the unindexed library). (#523) */
cbm_svc_kind_t csvc = cbm_service_pattern_match(call->callee_name);
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 = 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, csvc, false);
return SKIP_ONE;
}
}
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn,
imp_keys, imp_vals, imp_count);
if (!res.qualified_name || res.qualified_name[0] == '\0') {
/* Resolution is empty when the callee belongs to an EXTERNAL client
* library whose source is not in the indexed tree (e.g. `requests.get`,
* `httpx.post`) — the import map skips it (no node) and no project symbol
* matches. The service-pattern signal lives in the RAW callee_name
* ("requests.get" contains "requests"), so classify on that and emit the
* HTTP_CALLS/ASYNC_CALLS edge directly (target is a synthesized route
* node, not the absent library). Without this the call is dropped and
* cross-repo matching finds no edge to match (#523). The parallel path
* has the equivalent empty-resolution fallback in resolve_file_calls.
*
* Native `fetch()` (#856) belongs here too, not in the substring
* tables above: it only counts as the global API once resolution has
* already failed to find a local/imported `fetch` definition. */
/* Route registration on an unresolvable callee (#952): facade-style
* Laravel (`Route::get('/x', ...)`) — the facade class lives in
* vendor/ and is never indexed, so resolution is ALWAYS empty in real
* 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);
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;
}
}
return 0;
}
/* Perl call-graph noise guard (#476). Perl has no LSP resolver, so the
* generic registry chain is the only resolver; for builtins (push/shift/
* keys/...) and method calls ($obj->m with an unresolved receiver), a *weak*
* cross-file short-name match to a project sub sharing the name is almost
* always a false positive. Suppress only those weak matches; KEEP the
* high-confidence same_module / import_map strategies so a genuine
* same-file or imported call to a builtin-named sub still resolves. Gated
* to Perl — other languages are unaffected. */
if (cbm_perl_suppress_generic_match(lang == CBM_LANG_PERL, call->is_method, call->callee_name,
res.strategy)) {
return 0;
}
/* TS/JS/TSX weak-method suppression (#592/#606). A member call x.foo() only
* reaches the registry when the TS-LSP could not resolve the receiver type
* (the LSP block above already returned for type-resolved calls, including
* the "resolved but target out of gbuf" fall-through). Binding such a call
* by a weak short-name strategy fabricates an edge (`re.test()` -> a project
* `test`). Rather than drop it here — which would also skip the service
* bypasses below and emit_classified_edge's route/HTTP/CONFIG branches —
* defer to emit_classified_edge and suppress ONLY the plain-CALLS
* fall-through, so every service edge stays main-identical. res.strategy may
* be lsp_* here; the helper's explicit drop-list leaves lsp_* untouched. */
bool is_tsjs =
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool tsjs_drop_plain_call =
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
* name ("requests"), but that library is not in the indexed tree so
* cbm_gbuf_find_by_qn returns NULL. The edge target for such calls is a
* SYNTHESIZED route node (create_svc_route_node), not the library node, so
* 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;
}
}
const cbm_gbuf_node_t *target_node = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
if (!target_node || source_node->id == target_node->id) {
return 0;
}
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
imp_count, tsjs_drop_plain_call);
return SKIP_ONE;
}
static CBMFileResult *calls_get_or_extract(cbm_pipeline_ctx_t *ctx, int idx,
const cbm_file_info_t *fi, bool *owned) {
*owned = false;
if (ctx->result_cache && ctx->result_cache[idx]) {
return ctx->result_cache[idx];
}
int slen = 0;
char *src = read_file(fi->path, &slen);
if (!src) {
return NULL;
}
CBMFileResult *r = cbm_extract_file(src, slen, fi->language, ctx->project_name, fi->rel_path,
CBM_EXTRACT_BUDGET, NULL, NULL);
free(src);
if (r) {
*owned = true;
}
return r;
}
int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count) {
cbm_log_info("pass.start", "pass", "calls", "files", itoa_log(file_count));
int total_calls = 0;
int resolved = 0;
int unresolved = 0;
int errors = 0;
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
return CBM_NOT_FOUND;
}
const char *rel = files[i].rel_path;
bool result_owned = false;
CBMFileResult *result = calls_get_or_extract(ctx, i, &files[i], &result_owned);
if (!result) {
errors++;
continue;
}
if (result->calls.count == 0) {
if (result_owned) {
cbm_free_result(result);
}
continue;
}
/* Build import map for this file */
const char **imp_keys = NULL;
const char **imp_vals = NULL;
int imp_count = 0;
build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count);
/* Compute module QN for same-module resolution (directory-based for
* Java/Go so it matches their def-node QNs in the registry). */
char *module_qn = cbm_pipeline_fqn_module_dir(ctx->project_name, rel,
pc_module_is_dir(files[i].language));
/* Resolve each call */
for (int c = 0; c < result->calls.count; c++) {
CBMCall *call = &result->calls.items[c];
if (!call->callee_name) {
continue;
}
total_calls++;
if (resolve_single_call(ctx, call, &result->resolved_calls, rel, module_qn, imp_keys,
imp_vals, imp_count, files[i].language)) {
resolved++;
} else {
unresolved++;
}
}
free(module_qn);
free_import_map(imp_keys, imp_vals, imp_count);
if (result_owned) {
cbm_free_result(result);
}
}
cbm_log_info("pass.done", "pass", "calls", "total", itoa_log(total_calls), "resolved",
itoa_log(resolved), "unresolved", itoa_log(unresolved), "errors",
itoa_log(errors));
/* Additional pattern-based edge passes run after normal call resolution */
cbm_pipeline_pass_fastapi_depends(ctx, files, file_count);
return 0;
}
/* ── FastAPI Depends() tracking ──────────────────────────────────── */
/* Scans Python function signatures for Depends(func_ref) patterns and
* creates CALLS edges from the endpoint to the dependency function.
* Without this, FastAPI auth/DI functions appear as dead code (in_degree=0). */
/* Extract Python function signature text from source starting at given line. Caller frees. */
static char *extract_py_signature(const char *source, int start_line, int end_line) {
int sig_end = start_line + PC_SIG_SCAN;
if (end_line > 0 && sig_end > end_line) {
sig_end = end_line;
}
const char *p = source;
int line = SKIP_ONE;
while (*p && line < start_line) {
if (*p == '\n') {
line++;
}
p++;
}
const char *sig_start = p;
while (*p && line < sig_end) {
if (*p == '\n') {
line++;
}
p++;
if (p > sig_start + SKIP_ONE && p[-SKIP_ONE] == ':' && p[-PAIR_LEN] == ')') {
break;
}
}
size_t sig_len = (size_t)(p - sig_start);
char *sig = malloc(sig_len + SKIP_ONE);
if (!sig) {
return NULL;
}
memcpy(sig, sig_start, sig_len);
sig[sig_len] = '\0';
return sig;
}
/* Scan one function's signature for Depends(func_ref) and create CALLS edges. */
static int scan_depends_in_sig(cbm_pipeline_ctx_t *ctx, const cbm_regex_t *re, const char *sig,
const CBMDefinition *def, const char *module_qn, const char **ik,
const char **iv, int ic) {
int count = 0;
cbm_regmatch_t match[PC_REGEX_GRP];
const char *scan = sig;
while (cbm_regexec(re, scan, PC_REGEX_GRP, match, 0) == 0) {
int ref_len = match[SKIP_ONE].rm_eo - match[SKIP_ONE].rm_so;
char func_ref[CBM_SZ_256];
if (ref_len >= (int)sizeof(func_ref)) {
ref_len = (int)sizeof(func_ref) - SKIP_ONE;
}
memcpy(func_ref, scan + match[SKIP_ONE].rm_so, (size_t)ref_len);
func_ref[ref_len] = '\0';
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, func_ref, module_qn, ik, iv, ic);
if (res.qualified_name && res.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *sn = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name);
const cbm_gbuf_node_t *tn = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
if (sn && tn && sn->id != tn->id) {
cbm_gbuf_insert_edge(ctx->gbuf, sn->id, tn->id, "CALLS",
"{\"confidence\":0.95,\"strategy\":\"fastapi_depends\"}");
count++;
}
}
scan += match[0].rm_eo;
}
return count;
}
static bool is_callable_def(const CBMDefinition *def) {
return def->qualified_name && def->start_line > 0 && def->label &&
(strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0);
}
static bool file_has_depends_call(const CBMFileResult *result) {
for (int c = 0; c < result->calls.count; c++) {
if (result->calls.items[c].callee_name &&
strcmp(result->calls.items[c].callee_name, "Depends") == 0) {
return true;
}
}
return false;
}
void cbm_pipeline_pass_fastapi_depends(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count) {
cbm_regex_t depends_re;
if (cbm_regcomp(&depends_re, "Depends\\(([A-Za-z_][A-Za-z0-9_.]*)", CBM_REG_EXTENDED) != 0) {
return;
}
int edge_count = 0;
for (int i = 0; i < file_count; i++) {
if (files[i].language != CBM_LANG_PYTHON) {
continue;
}
if (cbm_pipeline_check_cancel(ctx)) {
break;
}
CBMFileResult *result = ctx->result_cache ? ctx->result_cache[i] : NULL;
if (!result || !file_has_depends_call(result)) {
continue;
}
/* Read source and scan for Depends(func_ref) in function signatures */
int source_len = 0;
char *source = read_file(files[i].path, &source_len);
if (!source) {
continue;
}
char *module_qn = cbm_pipeline_fqn_module_dir(ctx->project_name, files[i].rel_path,
pc_module_is_dir(files[i].language));
/* Build import map for alias resolution */
const char **imp_keys = NULL;
const char **imp_vals = NULL;
int imp_count = 0;
build_import_map(ctx, files[i].rel_path, result, &imp_keys, &imp_vals, &imp_count);
for (int d = 0; d < result->defs.count; d++) {
CBMDefinition *def = &result->defs.items[d];
if (!is_callable_def(def)) {
continue;
}
char *sig = extract_py_signature(source, (int)def->start_line, (int)def->end_line);
if (!sig) {
continue;
}
edge_count += scan_depends_in_sig(ctx, &depends_re, sig, def, module_qn, imp_keys,
imp_vals, imp_count);
free(sig);
}
free(module_qn);
free_import_map(imp_keys, imp_vals, imp_count);
free(source);
}
cbm_regfree(&depends_re);
if (edge_count > 0) {
cbm_log_info("pass.fastapi_depends", "edges", itoa_log(edge_count));
}
}
/* DLL resolve tracking removed — triggered Windows Defender false positive.
* See issue #89. */
+285
View File
@@ -0,0 +1,285 @@
/*
* pass_compile_commands.c — compile_commands.json parsing helpers.
*
* Parses compile_commands.json to extract per-file include paths, defines,
* and C/C++ standard flags.
*/
#include "foundation/constants.h"
enum { CC_FLAG_IDX = 1, CC_FLAG_SKIP = 2 };
#define SLEN(s) (sizeof(s) - 1)
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "yyjson/yyjson.h"
/* Emit the current token if non-empty. Returns updated count. */
static int emit_token(char *current, int *clen, char **out, int count, int max_out) {
if (*clen > 0 && count < max_out) {
current[*clen] = '\0';
out[count++] = strdup(current);
*clen = 0;
}
return count;
}
int cbm_split_command(const char *cmd, char **out, int max_out) {
if (!cmd || !out || max_out <= 0) {
return 0;
}
int count = 0;
char current[CBM_SZ_4K];
int clen = 0;
char in_quote = 0;
for (int i = 0; cmd[i]; i++) {
char c = cmd[i];
if (in_quote) {
if (c == in_quote) {
in_quote = 0;
} else if (clen < (int)sizeof(current) - SKIP_ONE) {
current[clen++] = c;
}
} else if (c == '"' || c == '\'') {
in_quote = c;
} else if (c == ' ' || c == '\t') {
count = emit_token(current, &clen, out, count, max_out);
} else if (clen < (int)sizeof(current) - SKIP_ONE) {
current[clen++] = c;
}
}
return emit_token(current, &clen, out, count, max_out);
}
/* Resolve a path: if relative, join with directory. */
static char *resolve_path(const char *path, const char *directory) {
if (!path) {
return NULL;
}
/* Absolute path */
if (path[0] == '/') {
return strdup(path);
}
/* Relative — join with directory */
if (directory && directory[0]) {
char buf[CBM_SZ_4K];
snprintf(buf, sizeof(buf), "%s/%s", directory, path);
return strdup(buf);
}
return strdup(path);
}
/* Try to consume a -I or -isystem include path flag. Returns true if consumed. */
static bool try_include_flag(cbm_compile_flags_t *f, const char **args, int argc, int *i,
const char *directory) {
const char *arg = args[*i];
if (arg[0] == '-' && arg[CC_FLAG_IDX] == 'I') {
const char *path = arg + CC_FLAG_SKIP;
if (*path == '\0' && *i + SKIP_ONE < argc) {
(*i)++;
path = args[*i];
}
if (path && *path) {
f->include_paths[f->include_count++] = resolve_path(path, directory);
}
return true;
}
if (strcmp(arg, "-isystem") == 0 && *i + SKIP_ONE < argc) {
(*i)++;
f->include_paths[f->include_count++] = resolve_path(args[*i], directory);
return true;
}
return false;
}
/* Try to consume a -D define flag. Returns true if consumed. */
static bool try_define_flag(cbm_compile_flags_t *f, const char **args, int argc, int *i) {
const char *arg = args[*i];
if (arg[0] != '-' || arg[CC_FLAG_IDX] != 'D') {
return false;
}
const char *define = arg + CC_FLAG_SKIP;
if (*define == '\0' && *i + SKIP_ONE < argc) {
(*i)++;
define = args[*i];
}
if (define && *define) {
f->defines[f->define_count++] = strdup(define);
}
return true;
}
cbm_compile_flags_t *cbm_extract_flags(const char **args, int argc, const char *directory) {
cbm_compile_flags_t *f = calloc(CBM_ALLOC_ONE, sizeof(*f));
if (!f) {
return NULL;
}
f->include_paths = calloc(argc, sizeof(char *));
f->defines = calloc(argc, sizeof(char *));
for (int i = 0; i < argc; i++) {
if (try_include_flag(f, args, argc, &i, directory)) {
continue;
}
if (try_define_flag(f, args, argc, &i)) {
continue;
}
if (strncmp(args[i], "-std=", SLEN("-std=")) == 0) {
snprintf(f->standard, sizeof(f->standard), "%s", args[i] + 5);
}
}
return f;
}
void cbm_compile_flags_free(cbm_compile_flags_t *f) {
if (!f) {
return;
}
for (int i = 0; i < f->include_count; i++) {
free(f->include_paths[i]);
}
free(f->include_paths);
for (int i = 0; i < f->define_count; i++) {
free(f->defines[i]);
}
free(f->defines);
free(f);
}
/* Extract compiler flag args from either "arguments" array or "command" string. */
static int extract_flag_args(yyjson_val *args_val, yyjson_val *cmd_val, const char **flag_args,
char **split_args, int max_args) {
if (args_val && yyjson_is_arr(args_val)) {
int n = 0;
yyjson_val *a;
yyjson_arr_iter aiter;
yyjson_arr_iter_init(args_val, &aiter);
while ((a = yyjson_arr_iter_next(&aiter)) && n < max_args) {
const char *s = yyjson_get_str(a);
if (s) {
flag_args[n++] = s;
}
}
return n;
}
if (cmd_val && yyjson_is_str(cmd_val)) {
int n = cbm_split_command(yyjson_get_str(cmd_val), split_args, max_args);
for (int j = 0; j < n; j++) {
flag_args[j] = split_args[j];
}
return n;
}
return 0;
}
/* Process a single compile_commands.json entry. Returns 1 if added, 0 otherwise. */
static int process_compile_entry(yyjson_val *entry, const char *repo_path, char **out_path,
cbm_compile_flags_t **out_flag) {
yyjson_val *dir_val = yyjson_obj_get(entry, "directory");
yyjson_val *file_val = yyjson_obj_get(entry, "file");
yyjson_val *cmd_val = yyjson_obj_get(entry, "command");
yyjson_val *args_val = yyjson_obj_get(entry, "arguments");
if (!file_val) {
return 0;
}
const char *directory = dir_val ? yyjson_get_str(dir_val) : "";
const char *file_path = yyjson_get_str(file_val);
if (!file_path) {
return 0;
}
char *split_args[CBM_SZ_256] = {NULL};
const char *flag_args[CBM_SZ_256];
int flag_argc = extract_flag_args(args_val, cmd_val, flag_args, split_args, CBM_SZ_256);
if (flag_argc == 0) {
return 0;
}
cbm_compile_flags_t *f = cbm_extract_flags(flag_args, flag_argc, directory);
if (cmd_val && yyjson_is_str(cmd_val)) {
for (int j = 0; j < flag_argc; j++) {
free(split_args[j]);
}
}
if (!f) {
return 0;
}
char abs_path[CBM_SZ_4K];
if (file_path[0] != '/' && directory && directory[0]) {
snprintf(abs_path, sizeof(abs_path), "%s/%s", directory, file_path);
} else {
snprintf(abs_path, sizeof(abs_path), "%s", file_path);
}
size_t repo_len = strlen(repo_path);
if (strncmp(abs_path, repo_path, repo_len) != 0 || abs_path[repo_len] != '/') {
cbm_compile_flags_free(f);
return 0;
}
*out_path = strdup(abs_path + repo_len + SKIP_ONE);
*out_flag = f;
return SKIP_ONE;
}
int cbm_parse_compile_commands(const char *json_data, const char *repo_path, char ***out_paths,
cbm_compile_flags_t ***out_flags) {
if (!json_data || !repo_path || !out_paths || !out_flags) {
return CBM_NOT_FOUND;
}
*out_paths = NULL;
*out_flags = NULL;
yyjson_doc *doc = yyjson_read(json_data, strlen(json_data), 0);
if (!doc) {
return CBM_NOT_FOUND;
}
yyjson_val *root = yyjson_doc_get_root(doc);
if (!yyjson_is_arr(root)) {
yyjson_doc_free(doc);
return CBM_NOT_FOUND;
}
int arr_len = (int)yyjson_arr_size(root);
if (arr_len == 0) {
yyjson_doc_free(doc);
return 0;
}
char **paths = calloc(arr_len, sizeof(char *));
cbm_compile_flags_t **flags = calloc(arr_len, sizeof(cbm_compile_flags_t *));
int count = 0;
yyjson_val *entry;
yyjson_arr_iter iter;
yyjson_arr_iter_init(root, &iter);
while ((entry = yyjson_arr_iter_next(&iter))) {
char *p = NULL;
cbm_compile_flags_t *f = NULL;
if (process_compile_entry(entry, repo_path, &p, &f)) {
paths[count] = p;
flags[count] = f;
count++;
}
}
yyjson_doc_free(doc);
*out_paths = paths;
*out_flags = flags;
return count;
}
+207
View File
@@ -0,0 +1,207 @@
/*
* pass_complexity.c — Interprocedural complexity propagation (Tier B).
*
* Tier A (in the extraction walk) stamps each Function/Method node with local
* structural metrics: complexity (cyclomatic), cognitive, loop_count, loop_depth.
* This pass propagates loop_depth along CALLS edges to estimate a worst-case
* *transitive* nested-loop degree: a function with a depth-1 loop that calls an
* O(n) helper is effectively O(n^2). The estimate assumes calls may occur inside
* loops (an upper bound) — it is a queryable bottleneck *candidate* signal, not a
* proof (true big-O is undecidable; cf. SPEED / Loopus). Cycles in the call graph
* are broken and flagged via a `recursive` property.
*
* Writes two extra node properties: transitive_loop_depth, recursive.
*/
#include "foundation/constants.h"
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "cbm.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <stdbool.h>
enum { CBM_TLD_MAX_DEPTH = 256 }; /* recursion-depth cap (cycle/stack guard) */
/* Int → string for structured logging (thread-safe ring buffer). */
static const char *itoa_cx(int val) {
enum { RING = 2, MASK = 1 };
static CBM_TLS char bufs[RING][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + 1) & MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Parse an integer "key":N from a flat JSON object. Returns def if absent. */
static int json_get_int(const char *json, const char *key, int dflt) {
if (!json) {
return dflt;
}
char pat[CBM_SZ_64];
snprintf(pat, sizeof(pat), "\"%s\":", key);
const char *p = strstr(json, pat);
if (!p) {
return dflt;
}
p += strlen(pat);
while (*p == ' ' || *p == '\t') {
p++;
}
return (int)strtol(p, NULL, CBM_DECIMAL_BASE);
}
/* Parse a boolean "key":true/false from a flat JSON object. */
static bool json_get_bool(const char *json, const char *key) {
if (!json) {
return false;
}
char pat[CBM_SZ_64];
snprintf(pat, sizeof(pat), "\"%s\":", key);
const char *p = strstr(json, pat);
if (!p) {
return false;
}
p += strlen(pat);
while (*p == ' ' || *p == '\t') {
p++;
}
return *p == 't';
}
/* Append transitive_loop_depth + recursive to a node's properties JSON object. */
static void append_complexity_props(cbm_gbuf_node_t *node, int tld, bool recursive) {
const char *old = node->properties_json ? node->properties_json : "{}";
size_t olen = strlen(old);
if (olen < 2 || old[olen - 1] != '}') {
return; /* not a JSON object — leave untouched */
}
bool empty = (olen == 2); /* "{}" */
char *neu = malloc(olen + CBM_SZ_64);
if (!neu) {
return;
}
memcpy(neu, old, olen - 1); /* copy without trailing '}' */
int w =
snprintf(neu + (olen - 1), CBM_SZ_64, "%s\"transitive_loop_depth\":%d,\"recursive\":%s}",
empty ? "" : ",", tld, recursive ? "true" : "false");
if (w < 0) {
free(neu);
return;
}
free(node->properties_json);
node->properties_json = neu;
}
/* Memoized DFS: tld(id) = loop_depth(id) + max over CALLS-callees of tld(callee).
* state: 0=unvisited, 1=in-progress (back-edge → cycle), 2=done. */
static int tld_dfs(const cbm_gbuf_t *gb, int64_t id, const int *loop_depth, int *tld, char *state,
bool *recursive, int64_t maxid, int depth) {
if (id < 1 || id > maxid) {
return 0;
}
if (state[id] == 2) {
return tld[id];
}
if (state[id] == 1) {
recursive[id] = true; /* back edge → call-graph cycle */
return 0;
}
if (depth > CBM_TLD_MAX_DEPTH) {
return loop_depth[id];
}
state[id] = 1;
int best = 0;
const cbm_gbuf_edge_t **edges = NULL;
int ne = 0;
cbm_gbuf_find_edges_by_source_type(gb, id, "CALLS", &edges, &ne);
for (int i = 0; i < ne; i++) {
int64_t c = edges[i]->target_id;
if (c == id) {
recursive[id] = true; /* direct self-recursion */
continue;
}
int ct = tld_dfs(gb, c, loop_depth, tld, state, recursive, maxid, depth + 1);
if (ct > best) {
best = ct;
}
}
tld[id] = loop_depth[id] + best;
state[id] = 2;
return tld[id];
}
/* Seed each Function/Method node's loop_depth and self_recursive flag, and
* remember the node pointer for write-back. The self_recursive seed (set at
* extraction) feeds the final recursive flag; tld_dfs additionally ORs in
* mutual recursion discovered as a call-graph cycle. */
static void seed_loop_depths(const cbm_gbuf_t *gb, const char *label, int *loop_depth,
bool *recursive, cbm_gbuf_node_t **nptr, int64_t maxid) {
const cbm_gbuf_node_t **nodes = NULL;
int count = 0;
if (cbm_gbuf_find_by_label(gb, label, &nodes, &count) != 0) {
return;
}
for (int i = 0; i < count; i++) {
const cbm_gbuf_node_t *n = nodes[i];
if (n->id >= 1 && n->id <= maxid) {
loop_depth[n->id] = json_get_int(n->properties_json, "loop_depth", 0);
recursive[n->id] = json_get_bool(n->properties_json, "self_recursive");
nptr[n->id] = (cbm_gbuf_node_t *)n;
}
}
}
void cbm_pipeline_pass_complexity(cbm_pipeline_ctx_t *ctx) {
cbm_gbuf_t *gb = ctx->gbuf;
/* Node and edge IDs are drawn from one shared counter, so node IDs are NOT
* contiguous 1..node_count — they interleave with edge IDs. Size the lookup
* arrays by the id ceiling (next_id) so every node id is addressable. */
int64_t maxid = cbm_gbuf_next_id(gb) - 1;
if (maxid < 1) {
return;
}
size_t sz = (size_t)maxid + 1;
int *loop_depth = calloc(sz, sizeof(int));
int *tld = calloc(sz, sizeof(int));
char *state = calloc(sz, sizeof(char));
bool *recursive = calloc(sz, sizeof(bool));
cbm_gbuf_node_t **nptr = calloc(sz, sizeof(cbm_gbuf_node_t *));
if (!loop_depth || !tld || !state || !recursive || !nptr) {
free(loop_depth);
free(tld);
free(state);
free(recursive);
free(nptr);
return;
}
seed_loop_depths(gb, "Function", loop_depth, recursive, nptr, maxid);
seed_loop_depths(gb, "Method", loop_depth, recursive, nptr, maxid);
int updated = 0;
for (int64_t id = 1; id <= maxid; id++) {
if (!nptr[id]) {
continue; /* only Function/Method nodes */
}
if (state[id] != 2) {
tld_dfs(gb, id, loop_depth, tld, state, recursive, maxid, 0);
}
append_complexity_props(nptr[id], tld[id], recursive[id]);
updated++;
}
cbm_log_info("pass.complexity", "functions", itoa_cx(updated));
free(loop_depth);
free(tld);
free(state);
free(recursive);
free(nptr);
}
+418
View File
@@ -0,0 +1,418 @@
/*
* pass_configlink.c — Config ↔ Code linking strategies (pre-dump pass).
*
* Three strategies link config files to code symbols:
* 1. Key→Symbol: normalized config key matches code function/variable name
* 2. Dep→Import: package manifest dependency matches IMPORTS edge target
* 3. File→Ref: source code string literal references config file path
*
* Operates on the graph buffer before dump to .db file.
*/
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/hash_table.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include "foundation/compat_regex.h"
/* ── Config link confidence scores ───────────────────────────────── */
/* Strategy 1: Key→Symbol matching */
#define CONF_KEY_EXACT 0.85
#define CONF_KEY_SUBSTRING 0.75
/* Strategy 2: Dep→Import matching */
#define CONF_DEP_EXACT 0.95
#define CONF_DEP_QN_SUBSTR 0.80
/* Strategy 3: File→Ref matching */
#define CONF_FILE_FULLPATH 0.90
#define CONF_FILE_BASENAME 0.70
/* ── Manifest / dep section tables ──────────────────────────────── */
static bool is_manifest_file(const char *basename) {
static const char *names[] = {"Cargo.toml", "package.json", "go.mod",
"requirements.txt", "Gemfile", "build.gradle",
"pom.xml", "composer.json", NULL};
for (int i = 0; names[i]; i++) {
if (strcmp(basename, names[i]) == 0) {
return true;
}
}
return false;
}
static bool is_dep_section(const char *s) {
static const char *secs[] = {"dependencies", "devdependencies", "peerdependencies",
"dev-dependencies", "build-dependencies", NULL};
for (int i = 0; secs[i]; i++) {
if (cbm_strcasestr(s, secs[i]) != NULL) {
return true;
}
}
return false;
}
/* ── Strategy 1: Config Key → Code Symbol ───────────────────────── */
typedef struct {
int64_t node_id;
char normalized[CBM_SZ_256];
char name[CBM_SZ_256];
} config_entry_t;
/* Collect config Variable nodes with ≥2 tokens, each ≥3 chars. */
static int collect_config_entries(const cbm_gbuf_node_t *const *vars, int var_count,
config_entry_t *out, int max_out) {
int n = 0;
for (int i = 0; i < var_count && n < max_out; i++) {
if (!cbm_has_config_extension(vars[i]->file_path)) {
continue;
}
char norm[CBM_SZ_256];
int tokens = cbm_normalize_config_key(vars[i]->name, norm, sizeof(norm));
if (tokens < PAIR_LEN) {
continue;
}
/* Check all tokens ≥3 chars */
bool all_long = true;
const char *p = norm;
while (*p) {
const char *end = strchr(p, '_');
size_t tlen = end ? (size_t)(end - p) : strlen(p);
if (tlen < CBM_SZ_3) {
all_long = false;
break;
}
p = end ? end + SKIP_ONE : p + tlen;
}
if (!all_long) {
continue;
}
out[n].node_id = vars[i]->id;
snprintf(out[n].normalized, sizeof(out[n].normalized), "%s", norm);
snprintf(out[n].name, sizeof(out[n].name), "%s", vars[i]->name);
n++;
}
return n;
}
/* Collect code nodes (Function/Variable/Class/Struct) not from config files. */
typedef struct {
int64_t node_id;
char normalized[CBM_SZ_256];
} code_entry_t;
static int collect_code_entries(cbm_gbuf_t *gb, code_entry_t *out, int max_out) {
int n = 0;
/* "Struct" alongside "Class": a config key may name a Go/Rust/Swift/D struct
* type, which is now labelled "Struct" — keep it linkable. */
static const char *labels[] = {"Function", "Variable", "Class", "Struct", NULL};
for (int li = 0; labels[li] && n < max_out; li++) {
const cbm_gbuf_node_t **nodes = NULL;
int count = 0;
if (cbm_gbuf_find_by_label(gb, labels[li], &nodes, &count) != 0) {
continue;
}
for (int i = 0; i < count && n < max_out; i++) {
if (cbm_has_config_extension(nodes[i]->file_path)) {
continue;
}
char norm[CBM_SZ_256];
int tokens = cbm_normalize_config_key(nodes[i]->name, norm, sizeof(norm));
if (tokens == 0 || norm[0] == '\0') {
continue;
}
out[n].node_id = nodes[i]->id;
snprintf(out[n].normalized, sizeof(out[n].normalized), "%s", norm);
n++;
}
/* gbuf data is borrowed — no free */
}
return n;
}
static int strategy_key_symbols(cbm_gbuf_t *gb) {
/* Get all Variable nodes */
const cbm_gbuf_node_t **vars = NULL;
int var_count = 0;
if (cbm_gbuf_find_by_label(gb, "Variable", &vars, &var_count) != 0) {
return 0;
}
config_entry_t config_entries[CBM_SZ_4K];
int config_count = collect_config_entries(vars, var_count, config_entries, CBM_SZ_4K);
if (config_count == 0) {
return 0;
}
code_entry_t code_entries[CBM_SZ_8K];
int code_count = collect_code_entries(gb, code_entries, CBM_SZ_8K);
int edge_count = 0;
for (int ci = 0; ci < config_count; ci++) {
for (int co = 0; co < code_count; co++) {
double confidence = 0.0;
if (strcmp(config_entries[ci].normalized, code_entries[co].normalized) == 0) {
/* Exact match */
confidence = CONF_KEY_EXACT;
} else if (strstr(code_entries[co].normalized, config_entries[ci].normalized) != NULL) {
/* Substring match */
confidence = CONF_KEY_SUBSTRING;
}
if (confidence > 0.0) {
char props[CBM_SZ_512];
snprintf(props, sizeof(props),
"{\"strategy\":\"key_symbol\",\"confidence\":%.2f,\"config_key\":\"%s\"}",
confidence, config_entries[ci].name);
cbm_gbuf_insert_edge(gb, code_entries[co].node_id, config_entries[ci].node_id,
"CONFIGURES", props);
edge_count++;
}
}
}
return edge_count;
}
/* ── Strategy 2: Dependency → Import ────────────────────────────── */
typedef struct {
int64_t node_id;
char name[CBM_SZ_256];
} dep_entry_t;
/* Extract basename from a file path. */
static const char *path_basename(const char *path) {
if (!path) {
return "";
}
const char *slash = strrchr(path, '/');
return slash ? slash + SKIP_ONE : path;
}
/* Check if a Cargo.toml QN contains a dependency section in any dotted part. */
static bool is_cargo_dep_section(const char *qn) {
char qn_copy[CBM_SZ_512];
snprintf(qn_copy, sizeof(qn_copy), "%s", qn);
char *saveptr = NULL;
char *part = strtok_r(qn_copy, ".", &saveptr);
while (part) {
char lower[CBM_SZ_128];
size_t plen = strlen(part);
if (plen >= sizeof(lower)) {
plen = sizeof(lower) - SKIP_ONE;
}
for (size_t j = 0; j < plen; j++) {
lower[j] = (char)tolower((unsigned char)part[j]);
}
lower[plen] = '\0';
static const char *dep_secs[] = {"dependencies", "devdependencies",
"peerdependencies", "dev-dependencies",
"build-dependencies", NULL};
for (int k = 0; dep_secs[k]; k++) {
if (strcmp(lower, dep_secs[k]) == 0) {
return true;
}
}
part = strtok_r(NULL, ".", &saveptr);
}
return false;
}
static int collect_manifest_deps(const cbm_gbuf_node_t *const *vars, int var_count,
dep_entry_t *out, int max_out) {
int n = 0;
for (int i = 0; i < var_count && n < max_out; i++) {
const char *base = path_basename(vars[i]->file_path);
if (!is_manifest_file(base)) {
continue;
}
bool is_dep = vars[i]->qualified_name && is_dep_section(vars[i]->qualified_name);
if (!is_dep && strcmp(base, "Cargo.toml") == 0 && vars[i]->qualified_name) {
is_dep = is_cargo_dep_section(vars[i]->qualified_name);
}
if (is_dep) {
out[n].node_id = vars[i]->id;
snprintf(out[n].name, sizeof(out[n].name), "%s", vars[i]->name);
n++;
}
}
return n;
}
/* Lowercase a string into buf. */
static void lowercase_into(char *buf, size_t bufsize, const char *src) {
size_t len = src ? strlen(src) : 0;
for (size_t j = 0; j < len && j < bufsize - SKIP_ONE; j++) {
buf[j] = (char)tolower((unsigned char)src[j]);
}
buf[len < bufsize ? len : bufsize - SKIP_ONE] = '\0';
}
/* Match a dep name (lowercased) against an import target node.
* Returns confidence > 0 on match, 0 on no match. */
static double match_dep_to_import(const cbm_gbuf_node_t *target, const char *dep_lower) {
char target_lower[CBM_SZ_256];
lowercase_into(target_lower, sizeof(target_lower), target->name);
if (strcmp(target_lower, dep_lower) == 0) {
return CONF_DEP_EXACT;
}
if (target->qualified_name) {
char qn_lower[CBM_SZ_512];
lowercase_into(qn_lower, sizeof(qn_lower), target->qualified_name);
if (strstr(qn_lower, dep_lower) != NULL) {
return CONF_DEP_QN_SUBSTR;
}
}
return 0.0;
}
static int strategy_dep_imports(cbm_gbuf_t *gb) {
const cbm_gbuf_node_t **vars = NULL;
int var_count = 0;
if (cbm_gbuf_find_by_label(gb, "Variable", &vars, &var_count) != 0) {
return 0;
}
dep_entry_t deps[CBM_SZ_2K];
int dep_count = collect_manifest_deps(vars, var_count, deps, CBM_SZ_2K);
if (dep_count == 0) {
return 0;
}
/* Get all IMPORTS edges */
const cbm_gbuf_edge_t **imports = NULL;
int import_count = 0;
if (cbm_gbuf_find_edges_by_type(gb, "IMPORTS", &imports, &import_count) != 0) {
return 0;
}
int edge_count = 0;
for (int di = 0; di < dep_count; di++) {
char dep_lower[CBM_SZ_256];
lowercase_into(dep_lower, sizeof(dep_lower), deps[di].name);
for (int ii = 0; ii < import_count; ii++) {
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(gb, imports[ii]->target_id);
if (!target) {
continue;
}
const cbm_gbuf_node_t *source = cbm_gbuf_find_by_id(gb, imports[ii]->source_id);
if (!source) {
continue;
}
double confidence = match_dep_to_import(target, dep_lower);
if (confidence > 0.0) {
char props[CBM_SZ_512];
snprintf(
props, sizeof(props),
"{\"strategy\":\"dependency_import\",\"confidence\":%.2f,\"dep_name\":\"%s\"}",
confidence, deps[di].name);
cbm_gbuf_insert_edge(gb, source->id, deps[di].node_id, "CONFIGURES", props);
edge_count++;
}
}
}
/* gbuf data is borrowed — no free */
return edge_count;
}
/* ── Strategy 3: Config File Path → Code String Reference ───────── */
typedef struct {
const char *key;
int64_t node_id;
} path_map_t;
/* Match a ref_path against config module maps. Returns target node_id (0 = no match). */
int cbm_pipeline_pass_configlink(cbm_pipeline_ctx_t *ctx) {
cbm_gbuf_t *gb = ctx->gbuf;
/* Early exit: check if any config files exist in the project. */
bool has_config = false;
const cbm_gbuf_node_t **vars_check = NULL;
int var_check_count = 0;
if (!has_config && cbm_gbuf_find_by_label(gb, "Variable", &vars_check, &var_check_count) == 0) {
for (int i = 0; i < var_check_count; i++) {
if (cbm_has_config_extension(vars_check[i]->file_path)) {
has_config = true;
break;
}
}
}
if (!has_config) {
const cbm_gbuf_node_t **mods_check = NULL;
int mod_check_count = 0;
if (cbm_gbuf_find_by_label(gb, "Module", &mods_check, &mod_check_count) == 0) {
for (int i = 0; i < mod_check_count; i++) {
if (cbm_has_config_extension(mods_check[i]->file_path)) {
has_config = true;
break;
}
}
}
}
if (!has_config) {
cbm_log_info("configlinker.skip", "reason", "no_config_files");
return 0;
}
char buf1[CBM_SZ_16];
char buf2[CBM_SZ_16];
char buf3[CBM_SZ_16];
char buf4[CBM_SZ_16];
int key_edges = strategy_key_symbols(gb);
snprintf(buf1, sizeof(buf1), "%d", key_edges);
cbm_log_info("configlinker.strategy", "name", "key_symbol", "edges", buf1);
int dep_edges = strategy_dep_imports(gb);
snprintf(buf2, sizeof(buf2), "%d", dep_edges);
cbm_log_info("configlinker.strategy", "name", "dep_import", "edges", buf2);
int ref_edges = 0;
if (ctx->repo_path) {
/* File refs: no longer reads from disk — config file path matching
* is handled by CONFIGURES edges created during resolution. */
ref_edges = 0;
}
snprintf(buf3, sizeof(buf3), "%d", ref_edges);
cbm_log_info("configlinker.strategy", "name", "file_ref", "edges", buf3);
snprintf(buf4, sizeof(buf4), "%d", key_edges + dep_edges + ref_edges);
cbm_log_info("configlinker.done", "total", buf4);
return key_edges + dep_edges + ref_edges;
}
+131
View File
@@ -0,0 +1,131 @@
/*
* pass_configures.c — Config key and env var helpers.
*
* Pure helper functions for detecting environment variable names,
* normalizing config keys, and identifying config file extensions.
*/
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include <ctype.h>
#include <string.h>
bool cbm_is_env_var_name(const char *s) {
if (!s) {
return false;
}
size_t len = strlen(s);
if (len < PAIR_LEN) {
return false;
}
bool has_upper = false;
for (size_t i = 0; i < len; i++) {
char c = s[i];
if (c >= 'A' && c <= 'Z') {
has_upper = true;
} else if (c == '_' || (c >= '0' && c <= '9')) {
/* ok */
} else {
return false;
}
}
return has_upper;
}
/* Emit a camelCase-split word (lowercased) into the output buffer.
* Prepends '_' if out_pos > 0. Returns updated out_pos and token_count. */
static void emit_camel_words(const char *part, size_t plen, char *norm_out, size_t norm_sz,
size_t *out_pos, int *token_count) {
size_t start = 0;
for (size_t i = SKIP_ONE; i < plen; i++) {
if (part[i] >= 'A' && part[i] <= 'Z' && part[i - SKIP_ONE] >= 'a' &&
part[i - SKIP_ONE] <= 'z') {
if (*out_pos > 0 && *out_pos < norm_sz - SKIP_ONE) {
norm_out[(*out_pos)++] = '_';
}
for (size_t j = start; j < i && *out_pos < norm_sz - SKIP_ONE; j++) {
norm_out[(*out_pos)++] = (char)tolower((unsigned char)part[j]);
}
(*token_count)++;
start = i;
}
}
/* Emit remaining */
if (*out_pos > 0 && *out_pos < norm_sz - SKIP_ONE) {
norm_out[(*out_pos)++] = '_';
}
for (size_t j = start; j < plen && *out_pos < norm_sz - SKIP_ONE; j++) {
norm_out[(*out_pos)++] = (char)tolower((unsigned char)part[j]);
}
(*token_count)++;
}
int cbm_normalize_config_key(const char *key, char *norm_out, size_t norm_sz) {
if (!key || !norm_out || norm_sz == 0) {
return 0;
}
norm_out[0] = '\0';
char buf[CBM_SZ_512];
size_t klen = strlen(key);
if (klen >= sizeof(buf)) {
klen = sizeof(buf) - SKIP_ONE;
}
memcpy(buf, key, klen);
buf[klen] = '\0';
/* Replace delimiters with spaces */
for (size_t i = 0; i < klen; i++) {
if (buf[i] == '_' || buf[i] == '-' || buf[i] == '.') {
buf[i] = ' ';
}
}
int token_count = 0;
size_t out_pos = 0;
char *saveptr = NULL;
char *part = strtok_r(buf, " ", &saveptr);
while (part) {
emit_camel_words(part, strlen(part), norm_out, norm_sz, &out_pos, &token_count);
part = strtok_r(NULL, " ", &saveptr);
}
norm_out[out_pos] = '\0';
return token_count;
}
bool cbm_has_config_extension(const char *path) {
if (!path) {
return false;
}
/* Find last dot */
const char *dot = strrchr(path, '.');
const char *basename = strrchr(path, '/');
if (!basename) {
basename = path;
} else {
basename++;
}
/* Special case: .env files */
if (strcmp(basename, ".env") == 0) {
return true;
}
if (!dot) {
return false;
}
static const char *exts[] = {".toml", ".ini", ".yaml", ".yml", ".cfg", ".properties",
".json", ".xml", ".conf", ".env", NULL};
for (int i = 0; exts[i]; i++) {
if (strcmp(dot, exts[i]) == 0) {
return true;
}
}
return false;
}
+837
View File
@@ -0,0 +1,837 @@
/*
* pass_cross_repo.c — Cross-repo intelligence: match Routes, Channels, and
* async topics across indexed projects to create CROSS_* edges.
*
* For each HTTP_CALLS/ASYNC_CALLS edge in the source project, looks up the
* target Route QN in other project DBs. For each Channel node with EMITS
* edges, looks for matching LISTENS_ON in other projects (and vice versa).
*
* Edges are written bidirectionally: both source and target project DBs
* get a CROSS_* edge so the link is visible from either side.
*/
#include "pipeline/pass_cross_repo.h"
#include "pipeline/pipeline_internal.h" // cbm_route_canon_path
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include <sqlite3/sqlite3.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* ── Constants ───────────────────────────────────────────────────── */
enum {
CR_PATH_BUF = 1024,
CR_QN_BUF = 512,
CR_PROPS_BUF = 2048,
CR_MAX_EDGES = 4096,
CR_DB_EXT_LEN = 3, /* strlen(".db") */
CR_INIT_CAP = 32,
CR_COL_3 = 3,
CR_COL_4 = 4,
CR_SCHEME_SKIP = 3, /* strlen("://") */
CR_ROUTE_PREFIX_LEN = 9, /* strlen("__route__") */
CR_ANY_LEN = 3, /* strlen("ANY") */
};
#define CR_MS_PER_SEC 1000.0
#define CR_NS_PER_MS 1000000.0
/* TLS buffer for integer-to-string in log calls. */
static CBM_TLS char cr_ibuf[CBM_SZ_32];
static const char *cr_itoa(int v) {
snprintf(cr_ibuf, sizeof(cr_ibuf), "%d", v);
return cr_ibuf;
}
/* ── Helpers ─────────────────────────────────────────────────────── */
static const char *cr_cache_dir(void) {
const char *dir = cbm_resolve_cache_dir();
return dir ? dir : cbm_tmpdir();
}
static void cr_db_path(const char *project, char *buf, size_t bufsz) {
snprintf(buf, bufsz, "%s/%s.db", cr_cache_dir(), project);
}
/* Extract a JSON string property from properties_json.
* Writes into buf, returns buf on success, NULL on miss. */
static const char *json_str_prop(const char *json, const char *key, char *buf, size_t bufsz) {
if (!json || !key) {
return NULL;
}
char pat[CBM_SZ_128];
snprintf(pat, sizeof(pat), "\"%s\":\"", key);
const char *start = strstr(json, pat);
if (!start) {
return NULL;
}
start += strlen(pat);
const char *end = strchr(start, '"');
if (!end) {
return NULL;
}
size_t len = (size_t)(end - start);
if (len >= bufsz) {
len = bufsz - SKIP_ONE;
}
memcpy(buf, start, len);
buf[len] = '\0';
return buf;
}
/* Build CROSS_* edge properties JSON. */
static void build_cross_props(char *buf, size_t bufsz, const char *target_project,
const char *target_function, const char *target_file,
const char *url_or_channel, const char *extra_key,
const char *extra_val) {
int n = snprintf(buf, bufsz,
"{\"target_project\":\"%s\",\"target_function\":\"%s\","
"\"target_file\":\"%s\"",
target_project ? target_project : "", target_function ? target_function : "",
target_file ? target_file : "");
if (url_or_channel && url_or_channel[0]) {
n += snprintf(buf + n, bufsz - (size_t)n, ",\"%s\":\"%s\"",
extra_key ? extra_key : "url_path", url_or_channel);
}
if (extra_val && extra_val[0]) {
n += snprintf(buf + n, bufsz - (size_t)n, ",\"%s\":\"%s\"",
extra_key ? "transport" : "method", extra_val);
}
snprintf(buf + n, bufsz - (size_t)n, "}");
}
/* Delete all CROSS_* edges for a project from a store. */
static void delete_cross_edges(cbm_store_t *store, const char *project) {
cbm_store_delete_edges_by_type(store, project, "CROSS_HTTP_CALLS");
cbm_store_delete_edges_by_type(store, project, "CROSS_ASYNC_CALLS");
cbm_store_delete_edges_by_type(store, project, "CROSS_CHANNEL");
cbm_store_delete_edges_by_type(store, project, "CROSS_GRPC_CALLS");
cbm_store_delete_edges_by_type(store, project, "CROSS_GRAPHQL_CALLS");
cbm_store_delete_edges_by_type(store, project, "CROSS_TRPC_CALLS");
}
/* Insert a CROSS_* edge into a store. Idempotent by construction: the edges
* table is UNIQUE(source_id, target_id, type) and cbm_store_insert_edge
* upserts on conflict, so a pair reached from both match directions or on a
* repeat run never duplicates a row. (#523) */
static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t from_id,
int64_t to_id, const char *edge_type, const char *props) {
cbm_edge_t edge = {
.project = project,
.source_id = from_id,
.target_id = to_id,
.type = edge_type,
.properties_json = props,
};
cbm_store_insert_edge(store, &edge);
}
/* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the
* path. url_path property values are stored raw from the call's first string
* argument, so they can be full URLs ("scheme://host:port/v2/x") — and
* cbm_route_canon_path only canonicalizes placeholder syntax, never strips
* authorities. Returns "/" for a URL with no path after the host (a request
* against the bare base URL targets the root route). (#523) */
static const char *cr_url_path(const char *url) {
if (!url) {
return url;
}
const char *scheme_end = strstr(url, "://");
if (!scheme_end) {
return url; /* already a bare path */
}
const char *path_start = strchr(scheme_end + CR_SCHEME_SKIP, '/');
return path_start ? path_start : "/";
}
/* Look up a node's name and file_path by id. */
static void lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out, size_t name_sz,
char *file_out, size_t file_sz) {
name_out[0] = '\0';
file_out[0] = '\0';
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db, "SELECT name, file_path FROM nodes WHERE id = ?1", CBM_NOT_FOUND,
&st, NULL) != SQLITE_OK) {
return;
}
sqlite3_bind_int64(st, SKIP_ONE, node_id);
if (sqlite3_step(st) == SQLITE_ROW) {
const char *nm = (const char *)sqlite3_column_text(st, 0);
const char *fp = (const char *)sqlite3_column_text(st, SKIP_ONE);
if (nm) {
snprintf(name_out, name_sz, "%s", nm);
}
if (fp) {
snprintf(file_out, file_sz, "%s", fp);
}
}
sqlite3_finalize(st);
}
/* ── Phase A: HTTP Route matching ────────────────────────────────── */
/* Find a Route node in target_store by QN and return the handler function's
* node id, name, and file_path via HANDLES edges. Returns 0 if not found. */
static int64_t find_route_handler(cbm_store_t *target_store, const char *route_qn,
char *handler_name, size_t name_sz, char *handler_file,
size_t file_sz) {
handler_name[0] = '\0';
handler_file[0] = '\0';
struct sqlite3 *db = cbm_store_get_db(target_store);
if (!db) {
return 0;
}
/* Find Route node by QN */
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(
db, "SELECT id FROM nodes WHERE qualified_name = ?1 AND label = 'Route' LIMIT 1",
CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(s, SKIP_ONE, route_qn, CBM_NOT_FOUND, SQLITE_STATIC);
int64_t route_id = 0;
if (sqlite3_step(s) == SQLITE_ROW) {
route_id = sqlite3_column_int64(s, 0);
}
sqlite3_finalize(s);
if (route_id == 0) {
return 0;
}
/* Follow HANDLES edge to find the handler function */
if (sqlite3_prepare_v2(db,
"SELECT n.id, n.name, n.file_path FROM edges e "
"JOIN nodes n ON n.id = e.source_id "
"WHERE e.target_id = ?1 AND e.type = 'HANDLES' LIMIT 1",
CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_int64(s, SKIP_ONE, route_id);
int64_t handler_id = 0;
if (sqlite3_step(s) == SQLITE_ROW) {
handler_id = sqlite3_column_int64(s, 0);
const char *n = (const char *)sqlite3_column_text(s, SKIP_ONE);
const char *f = (const char *)sqlite3_column_text(s, PAIR_LEN);
if (n) {
snprintf(handler_name, name_sz, "%s", n);
}
if (f) {
snprintf(handler_file, file_sz, "%s", f);
}
}
sqlite3_finalize(s);
return handler_id;
}
/* Segment-wise match of a concrete path against a route template path, where a
* "{...}" segment in the template matches any single non-empty concrete
* segment. Both inputs are bare paths (no method prefix, no authority).
* Leading/trailing slashes are insignificant. Returns true on a full match. */
static bool cr_path_matches_template(const char *concrete, const char *templ) {
const char *c = concrete;
const char *t = templ;
while (*c && *t) {
if (*c == '/') {
c++;
}
if (*t == '/') {
t++;
}
const char *cseg = c;
while (*c && *c != '/') {
c++;
}
const char *tseg = t;
while (*t && *t != '/') {
t++;
}
size_t clen = (size_t)(c - cseg);
size_t tlen = (size_t)(t - tseg);
bool t_is_param = (tlen >= PAIR_LEN && tseg[0] == '{' && tseg[tlen - 1] == '}');
if (!t_is_param) {
if (clen != tlen || strncmp(cseg, tseg, clen) != 0) {
return false;
}
} else if (clen == 0) {
return false; /* a parameter never matches an empty segment */
}
}
while (*c == '/') {
c++;
}
while (*t == '/') {
t++;
}
return *c == '\0' && *t == '\0';
}
/* Fallback for when the exact route-QN lookup misses: a concrete client path
* ("/v2/orders/123") never exact-matches a templated route QN
* ("__route__GET__/v2/orders/{}"). Enumerate the target store's Route nodes
* and segment-match the concrete path against each template. On a match, copy
* the route QN into out_qn and return the handler id; returns 0 on no match.
*
* COST: this scans every Route node of the target project once per unmatched
* HTTP_CALLS edge — O(calls × routes) per project pair. Acceptable while both
* factors stay small (calls are capped at CR_MAX_EDGES and it only runs for
* edges the exact lookup missed); revisit with a prepared template index if
* cross-repo matching ever shows up in profiles. (#523) */
static int64_t find_route_handler_fuzzy(cbm_store_t *target_store, const char *concrete_path,
const char *method, char *out_qn, size_t out_qn_sz,
char *handler_name, size_t name_sz, char *handler_file,
size_t file_sz) {
struct sqlite3 *db = cbm_store_get_db(target_store);
if (!db || !concrete_path || !concrete_path[0]) {
return 0;
}
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(db, "SELECT qualified_name FROM nodes WHERE label = 'Route'",
CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
int64_t found = 0;
while (sqlite3_step(s) == SQLITE_ROW) {
const char *qn = (const char *)sqlite3_column_text(s, 0);
if (!qn || strncmp(qn, "__route__", CR_ROUTE_PREFIX_LEN) != 0) {
continue;
}
/* Split "__route__<METHOD>__<path>" */
const char *rest = qn + CR_ROUTE_PREFIX_LEN;
const char *sep = strstr(rest, "__");
if (!sep) {
continue;
}
size_t mlen = (size_t)(sep - rest);
const char *rpath = sep + PAIR_LEN;
/* Method gate: the route's method must equal the caller's, or be ANY.
* A missing caller method matches any route method. */
if (method && method[0]) {
bool same_method = (strncmp(rest, method, mlen) == 0 && method[mlen] == '\0');
bool route_any = (mlen == CR_ANY_LEN && strncmp(rest, "ANY", CR_ANY_LEN) == 0);
if (!same_method && !route_any) {
continue;
}
}
if (!cr_path_matches_template(concrete_path, rpath)) {
continue;
}
/* A concrete path can match more than one stored template (e.g. a raw
* "{id}" variant and its canonical "{}" form). Only accept a Route that
* actually has a HANDLES edge — the handler is attached to the
* canonical node. Keep scanning otherwise. */
int64_t hid =
find_route_handler(target_store, qn, handler_name, name_sz, handler_file, file_sz);
if (hid != 0) {
snprintf(out_qn, out_qn_sz, "%s", qn);
found = hid;
break;
}
}
sqlite3_finalize(s);
return found;
}
/* Emit CROSS_* edge for a route match: forward into source, reverse into target. */
static void emit_cross_route_bidirectional(cbm_store_t *src_store, const char *src_project,
struct sqlite3 *src_db, int64_t caller_id,
int64_t local_route_id, cbm_store_t *tgt_store,
const char *tgt_project, int64_t handler_id,
const char *route_qn, const char *handler_name,
const char *handler_file, const char *url_path,
const char *method, const char *edge_type) {
/* Forward: caller → local Route in source DB */
char fwd[CR_PROPS_BUF];
build_cross_props(fwd, sizeof(fwd), tgt_project, handler_name, handler_file, url_path,
"url_path", method);
insert_cross_edge(src_store, src_project, caller_id, local_route_id, edge_type, fwd);
/* Reverse: handler → Route in target DB */
struct sqlite3 *tgt_db = cbm_store_get_db(tgt_store);
if (!tgt_db) {
return;
}
sqlite3_stmt *rq = NULL;
if (sqlite3_prepare_v2(tgt_db, "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1",
CBM_NOT_FOUND, &rq, NULL) != SQLITE_OK) {
return;
}
sqlite3_bind_text(rq, SKIP_ONE, route_qn, CBM_NOT_FOUND, SQLITE_STATIC);
int64_t tgt_route_id = 0;
if (sqlite3_step(rq) == SQLITE_ROW) {
tgt_route_id = sqlite3_column_int64(rq, 0);
}
sqlite3_finalize(rq);
if (tgt_route_id == 0) {
return;
}
char caller_name[CBM_SZ_256] = {0};
char caller_file[CBM_SZ_512] = {0};
lookup_node_info(src_db, caller_id, caller_name, sizeof(caller_name), caller_file,
sizeof(caller_file));
char rev[CR_PROPS_BUF];
build_cross_props(rev, sizeof(rev), src_project, caller_name, caller_file, url_path, "url_path",
method);
insert_cross_edge(tgt_store, tgt_project, handler_id, tgt_route_id, edge_type, rev);
}
static int match_http_routes(cbm_store_t *src_store, const char *src_project,
cbm_store_t *tgt_store, const char *tgt_project) {
struct sqlite3 *src_db = cbm_store_get_db(src_store);
if (!src_db) {
return 0;
}
/* Find all HTTP_CALLS edges in source project */
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(src_db,
"SELECT e.source_id, e.target_id, e.properties FROM edges e "
"WHERE e.project = ?1 AND e.type = 'HTTP_CALLS'",
CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC);
int count = 0;
while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) {
int64_t caller_id = sqlite3_column_int64(s, 0);
int64_t route_id = sqlite3_column_int64(s, SKIP_ONE);
const char *props = (const char *)sqlite3_column_text(s, PAIR_LEN);
char url_path[CBM_SZ_256] = {0};
char method[CBM_SZ_32] = {0};
json_str_prop(props, "url_path", url_path, sizeof(url_path));
json_str_prop(props, "method", method, sizeof(method));
if (!url_path[0]) {
continue;
}
/* Build the expected Route QN in the target project (authority-stripped
* and param-canonicalized so client url_path matches the server handler
* regardless of base URL and framework placeholder syntax). */
char route_qn[CR_QN_BUF];
char cpath[CBM_SZ_256];
const char *curl = cbm_route_canon_path(cr_url_path(url_path), cpath, sizeof(cpath));
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method[0] ? method : "ANY", curl);
char handler_name[CBM_SZ_256] = {0};
char handler_file[CBM_SZ_512] = {0};
int64_t handler_id =
find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name),
handler_file, sizeof(handler_file));
if (handler_id == 0) {
/* Try without method (ANY) */
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", curl);
handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name),
handler_file, sizeof(handler_file));
}
if (handler_id == 0) {
/* Exact QN lookup missed. A concrete client path ("/v2/orders/123")
* never exact-matches a templated route ("/v2/orders/{}"), so fall
* back to segment-wise template matching. (#523) */
handler_id = find_route_handler_fuzzy(
tgt_store, curl, method[0] ? method : NULL, route_qn, sizeof(route_qn),
handler_name, sizeof(handler_name), handler_file, sizeof(handler_file));
}
if (handler_id == 0) {
continue;
}
emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id,
tgt_store, tgt_project, handler_id, route_qn, handler_name,
handler_file, url_path, method, "CROSS_HTTP_CALLS");
count++;
}
sqlite3_finalize(s);
return count;
}
/* ── Phase B: Async matching ─────────────────────────────────────── */
static int match_async_routes(cbm_store_t *src_store, const char *src_project,
cbm_store_t *tgt_store, const char *tgt_project) {
struct sqlite3 *src_db = cbm_store_get_db(src_store);
if (!src_db) {
return 0;
}
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(src_db,
"SELECT e.source_id, e.target_id, e.properties FROM edges e "
"WHERE e.project = ?1 AND e.type = 'ASYNC_CALLS'",
CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC);
int count = 0;
while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) {
int64_t caller_id = sqlite3_column_int64(s, 0);
int64_t route_id = sqlite3_column_int64(s, SKIP_ONE);
const char *props = (const char *)sqlite3_column_text(s, PAIR_LEN);
char url_path[CBM_SZ_256] = {0};
char broker[CBM_SZ_128] = {0};
json_str_prop(props, "url_path", url_path, sizeof(url_path));
json_str_prop(props, "broker", broker, sizeof(broker));
if (!url_path[0]) {
continue;
}
char route_qn[CR_QN_BUF];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", broker[0] ? broker : "async",
url_path);
char handler_name[CBM_SZ_256] = {0};
char handler_file[CBM_SZ_512] = {0};
int64_t handler_id =
find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name),
handler_file, sizeof(handler_file));
if (handler_id == 0) {
continue;
}
char edge_props[CR_PROPS_BUF];
build_cross_props(edge_props, sizeof(edge_props), tgt_project, handler_name, handler_file,
url_path, "url_path", broker);
insert_cross_edge(src_store, src_project, caller_id, route_id, "CROSS_ASYNC_CALLS",
edge_props);
count++;
}
sqlite3_finalize(s);
return count;
}
/* ── Phase C: Channel matching ───────────────────────────────────── */
/* Try to find a matching listener in target DB for a channel name. */
static bool try_match_channel_listener(cbm_store_t *src_store, const char *src_project,
cbm_store_t *tgt_store, const char *tgt_project,
const char *channel_name, const char *transport,
int64_t emitter_id, int64_t channel_id) {
struct sqlite3 *tgt_db = cbm_store_get_db(tgt_store);
if (!tgt_db) {
return false;
}
sqlite3_stmt *tq = NULL;
if (sqlite3_prepare_v2(tgt_db,
"SELECT n.id, e.source_id, fn.name, fn.file_path FROM nodes n "
"JOIN edges e ON e.target_id = n.id AND e.type = 'LISTENS_ON' "
"JOIN nodes fn ON fn.id = e.source_id "
"WHERE n.project = ?1 AND n.name = ?2 AND n.label = 'Channel' LIMIT 1",
CBM_NOT_FOUND, &tq, NULL) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(tq, SKIP_ONE, tgt_project, CBM_NOT_FOUND, SQLITE_STATIC);
sqlite3_bind_text(tq, PAIR_LEN, channel_name, CBM_NOT_FOUND, SQLITE_STATIC);
bool matched = false;
if (sqlite3_step(tq) == SQLITE_ROW) {
int64_t tgt_channel_id = sqlite3_column_int64(tq, 0);
int64_t listener_id = sqlite3_column_int64(tq, SKIP_ONE);
const char *listener_name = (const char *)sqlite3_column_text(tq, PAIR_LEN);
const char *listener_file = (const char *)sqlite3_column_text(tq, CR_COL_3);
/* Forward edge: emitter → local Channel */
char fwd[CR_PROPS_BUF];
build_cross_props(fwd, sizeof(fwd), tgt_project, listener_name ? listener_name : "",
listener_file ? listener_file : "", channel_name, "channel_name",
transport);
insert_cross_edge(src_store, src_project, emitter_id, channel_id, "CROSS_CHANNEL", fwd);
/* Reverse edge: listener → target Channel */
char caller_name[CBM_SZ_256] = {0};
char caller_file[CBM_SZ_512] = {0};
lookup_node_info(cbm_store_get_db(src_store), emitter_id, caller_name, sizeof(caller_name),
caller_file, sizeof(caller_file));
char rev[CR_PROPS_BUF];
build_cross_props(rev, sizeof(rev), src_project, caller_name, caller_file, channel_name,
"channel_name", transport);
insert_cross_edge(tgt_store, tgt_project, listener_id, tgt_channel_id, "CROSS_CHANNEL",
rev);
matched = true;
}
sqlite3_finalize(tq);
return matched;
}
static int match_channels(cbm_store_t *src_store, const char *src_project, cbm_store_t *tgt_store,
const char *tgt_project) {
struct sqlite3 *src_db = cbm_store_get_db(src_store);
if (!src_db) {
return 0;
}
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(src_db,
"SELECT DISTINCT n.id, n.name, n.qualified_name, n.properties, "
"e.source_id FROM nodes n "
"JOIN edges e ON e.target_id = n.id AND e.type = 'EMITS' "
"WHERE n.project = ?1 AND n.label = 'Channel'",
CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC);
int count = 0;
while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) {
const char *channel_name = (const char *)sqlite3_column_text(s, SKIP_ONE);
const char *channel_qn = (const char *)sqlite3_column_text(s, PAIR_LEN);
if (!channel_name || !channel_qn) {
continue;
}
int64_t channel_id = sqlite3_column_int64(s, 0);
const char *channel_props = (const char *)sqlite3_column_text(s, CR_COL_3);
int64_t emitter_id = sqlite3_column_int64(s, CR_COL_4);
char transport[CBM_SZ_64] = {0};
json_str_prop(channel_props, "transport", transport, sizeof(transport));
if (try_match_channel_listener(src_store, src_project, tgt_store, tgt_project, channel_name,
transport, emitter_id, channel_id)) {
count++;
}
}
sqlite3_finalize(s);
return count;
}
/* ── Phase D: Generic route-type matcher (gRPC, GraphQL, tRPC) ──── */
/* Look up a node's qualified_name by id. Returns true if found. */
static bool lookup_node_qn(struct sqlite3 *db, int64_t node_id, char *out, size_t out_sz) {
out[0] = '\0';
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db, "SELECT qualified_name FROM nodes WHERE id = ?1", CBM_NOT_FOUND, &st,
NULL) != SQLITE_OK) {
return false;
}
sqlite3_bind_int64(st, SKIP_ONE, node_id);
bool found = false;
if (sqlite3_step(st) == SQLITE_ROW) {
const char *qn = (const char *)sqlite3_column_text(st, 0);
if (qn) {
snprintf(out, out_sz, "%s", qn);
found = true;
}
}
sqlite3_finalize(st);
return found;
}
/* Match edges of a given type against Route nodes with a given QN prefix.
* Reuses the same infrastructure as HTTP/async matching. */
static int match_typed_routes(cbm_store_t *src_store, const char *src_project,
cbm_store_t *tgt_store, const char *tgt_project,
const char *edge_type, const char *svc_key, const char *method_key,
const char *cross_edge_type) {
struct sqlite3 *src_db = cbm_store_get_db(src_store);
if (!src_db) {
return 0;
}
char sql[CBM_SZ_256];
snprintf(sql, sizeof(sql),
"SELECT e.source_id, e.target_id, e.properties FROM edges e "
"WHERE e.project = ?1 AND e.type = '%s'",
edge_type);
sqlite3_stmt *s = NULL;
if (sqlite3_prepare_v2(src_db, sql, CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC);
int count = 0;
while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) {
int64_t caller_id = sqlite3_column_int64(s, 0);
int64_t route_id = sqlite3_column_int64(s, SKIP_ONE);
const char *props = (const char *)sqlite3_column_text(s, PAIR_LEN);
char svc_val[CBM_SZ_256] = {0};
char meth_val[CBM_SZ_256] = {0};
json_str_prop(props, svc_key, svc_val, sizeof(svc_val));
json_str_prop(props, method_key, meth_val, sizeof(meth_val));
if (!svc_val[0] && !meth_val[0]) {
continue;
}
/* Look up the Route QN from the target node (already points to the Route). */
char route_qn[CR_QN_BUF] = {0};
if (!lookup_node_qn(src_db, route_id, route_qn, sizeof(route_qn))) {
continue;
}
char handler_name[CBM_SZ_256] = {0};
char handler_file[CBM_SZ_512] = {0};
int64_t handler_id =
find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name),
handler_file, sizeof(handler_file));
if (handler_id == 0) {
continue;
}
emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id,
tgt_store, tgt_project, handler_id, route_qn, handler_name,
handler_file, svc_val, svc_key, cross_edge_type);
count++;
}
sqlite3_finalize(s);
return count;
}
/* ── Collect target projects ─────────────────────────────────────── */
/* When target_projects = ["*"], scan the cache directory for all .db files. */
static int collect_all_projects(char ***out) {
const char *dir = cr_cache_dir();
cbm_dir_t *d = cbm_opendir(dir);
if (!d) {
*out = NULL;
return 0;
}
int cap = CR_INIT_CAP;
int count = 0;
char **projects = malloc((size_t)cap * sizeof(char *));
cbm_dirent_t *ent;
while ((ent = cbm_readdir(d)) != NULL) {
size_t len = strlen(ent->name);
if (len < CR_COL_4 || strcmp(ent->name + len - CR_DB_EXT_LEN, ".db") != 0) {
continue;
}
if (strstr(ent->name, "_cross_repo") || strstr(ent->name, "_config")) {
continue;
}
if (strstr(ent->name, "-wal") || strstr(ent->name, "-shm")) {
continue;
}
if (count >= cap) {
cap *= PAIR_LEN;
char **tmp = realloc(projects, (size_t)cap * sizeof(char *));
if (!tmp) {
break;
}
projects = tmp;
}
/* Strip .db extension */
projects[count] = malloc(len - PAIR_LEN);
memcpy(projects[count], ent->name, len - CR_DB_EXT_LEN);
projects[count][len - CR_DB_EXT_LEN] = '\0';
count++;
}
cbm_closedir(d);
*out = projects;
return count;
}
static void free_project_list(char **projects, int count) {
for (int i = 0; i < count; i++) {
free(projects[i]);
}
free(projects);
}
/* ── Entry point ─────────────────────────────────────────────────── */
cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **target_projects,
int target_count) {
cbm_cross_repo_result_t result = {0};
struct timespec t0;
clock_gettime(CLOCK_MONOTONIC, &t0);
/* Open source project store (read-write) */
char src_path[CR_PATH_BUF];
cr_db_path(project, src_path, sizeof(src_path));
cbm_store_t *src_store = cbm_store_open_path(src_path);
if (!src_store) {
return result;
}
/* Clean existing CROSS_* edges for this project */
delete_cross_edges(src_store, project);
/* Resolve target projects */
char **resolved = NULL;
int resolved_count = 0;
bool own_list = false;
if (target_count == SKIP_ONE && strcmp(target_projects[0], "*") == 0) {
resolved_count = collect_all_projects(&resolved);
own_list = true;
} else {
resolved = (char **)target_projects;
resolved_count = target_count;
}
/* Match against each target */
for (int i = 0; i < resolved_count; i++) {
const char *tgt = resolved[i];
if (strcmp(tgt, project) == 0) {
continue; /* skip self */
}
char tgt_path[CR_PATH_BUF];
cr_db_path(tgt, tgt_path, sizeof(tgt_path));
/* Open target store read-write (for bidirectional edge writes) */
cbm_store_t *tgt_store = cbm_store_open_path(tgt_path);
if (!tgt_store) {
continue;
}
result.http_edges += match_http_routes(src_store, project, tgt_store, tgt);
/* Reverse direction: when this pass runs from the provider side, the
* consumer's HTTP_CALLS live in tgt, not src — the forward pass above
* finds nothing because the provider has no outbound calls. This also
* re-creates the provider-side reverse edges that delete_cross_edges
* just wiped, which a provider-side run previously destroyed for good.
* A caller edge lives in exactly one DB, so the two directions scan
* disjoint edge sets and never double-count a pair; the store's
* (source, target, type) upsert keeps re-recorded rows unique. (#523) */
result.http_edges += match_http_routes(tgt_store, tgt, src_store, project);
result.async_edges += match_async_routes(src_store, project, tgt_store, tgt);
result.channel_edges += match_channels(src_store, project, tgt_store, tgt);
result.grpc_edges += match_typed_routes(src_store, project, tgt_store, tgt, "GRPC_CALLS",
"service", "method", "CROSS_GRPC_CALLS");
result.graphql_edges +=
match_typed_routes(src_store, project, tgt_store, tgt, "GRAPHQL_CALLS", "operation",
"operation", "CROSS_GRAPHQL_CALLS");
result.trpc_edges += match_typed_routes(src_store, project, tgt_store, tgt, "TRPC_CALLS",
"procedure", "procedure", "CROSS_TRPC_CALLS");
result.projects_scanned++;
cbm_store_close(tgt_store);
}
cbm_store_close(src_store);
if (own_list) {
free_project_list(resolved, resolved_count);
}
struct timespec t1;
clock_gettime(CLOCK_MONOTONIC, &t1);
result.elapsed_ms = ((double)(t1.tv_sec - t0.tv_sec) * CR_MS_PER_SEC) +
((double)(t1.tv_nsec - t0.tv_nsec) / CR_NS_PER_MS);
int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges +
result.graphql_edges + result.trpc_edges;
cbm_log_info("cross_repo.done", "project", project, "total", cr_itoa(total));
return result;
}
+32
View File
@@ -0,0 +1,32 @@
/*
* pass_cross_repo.h — Cross-repo intelligence: match Routes, Channels, and
* async topics across indexed projects to create CROSS_* edges.
*/
#ifndef CBM_PASS_CROSS_REPO_H
#define CBM_PASS_CROSS_REPO_H
#include "store/store.h"
/* Result of a cross-repo matching run. */
typedef struct {
int http_edges; /* CROSS_HTTP_CALLS edges created */
int async_edges; /* CROSS_ASYNC_CALLS edges created */
int channel_edges; /* CROSS_CHANNEL edges created */
int grpc_edges; /* CROSS_GRPC_CALLS edges created */
int graphql_edges; /* CROSS_GRAPHQL_CALLS edges created */
int trpc_edges; /* CROSS_TRPC_CALLS edges created */
int projects_scanned;
double elapsed_ms;
} cbm_cross_repo_result_t;
/* Run cross-repo matching for `project` against `target_projects`.
* If target_count == 1 and target_projects[0] == "*", matches against all
* indexed projects. Writes CROSS_* edges bidirectionally into both the
* source and target project DBs.
*
* `project` must already be indexed (its .db must exist).
* Returns result with edge counts. */
cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **target_projects,
int target_count);
#endif /* CBM_PASS_CROSS_REPO_H */
+667
View File
@@ -0,0 +1,667 @@
/*
* pass_definitions.c — Extract definitions from source files.
*
* For each discovered file:
* 1. Read source content from disk
* 2. Call cbm_extract_file() to get defs, calls, imports
* 3. Create Function/Class/Method/Variable/Module nodes in graph buffer
* 4. Register callables in the function registry
* 5. Store import maps and call sites for later passes
*
* Depends on: extraction layer (cbm.h), graph_buffer, pipeline internals
*/
#include "foundation/constants.h"
enum { PD_RING = 4, PD_RING_MASK = 3, PD_JSON_MARGIN = 10, PD_ESC_MARGIN = 3, PD_ESC_SPACE = 2 };
/* Fixed bytes around a serialized JSON field: ,"key":"value" / ,"key":[...]
* -> comma + 2 key quotes + colon + 2 value quotes (resp. brackets). */
enum { PD_JSON_FIELD_OVERHEAD = 6 };
#include "pipeline/pipeline.h"
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "cbm.h"
#include "simhash/minhash.h"
#include "semantic/ast_profile.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Read entire file into heap-allocated buffer. Returns NULL on error.
* Caller must free(). Sets *out_len to byte count. *out_size receives the
* on-disk size and *out_status the failure reason, so the caller can attribute
* a skip to the right phase/reason (read vs oversized) instead of a silent
* drop. Both out params may be NULL. */
static char *read_file(const char *path, int *out_len, long *out_size,
cbm_read_status_t *out_status) {
if (out_size) {
*out_size = 0;
}
if (out_status) {
*out_status = CBM_READ_OK;
}
FILE *f = cbm_fopen(path, "rb");
if (!f) {
if (out_status) {
*out_status = CBM_READ_OPEN_FAIL;
}
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (out_size) {
*out_size = size;
}
if (size <= 0) {
(void)fclose(f);
if (out_status) {
*out_status = CBM_READ_EMPTY;
}
return NULL;
}
if (size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */
(void)fclose(f);
if (out_status) {
*out_status = CBM_READ_OVERSIZED;
}
return NULL;
}
/* +16 padding: tree-sitter's lexer peeks a few bytes past the final UTF-8
* character when computing lookahead, reading beyond the logical end.
* Over-allocate and zero the tail so that read stays in-bounds (ASan
* flags it as a heap-buffer-overflow otherwise; harmless but real UB). */
enum { CBM_TS_LOOKAHEAD_PAD = 16 };
char *buf = malloc((size_t)size + CBM_TS_LOOKAHEAD_PAD);
if (!buf) {
(void)fclose(f);
if (out_status) {
*out_status = CBM_READ_OOM;
}
return NULL;
}
size_t nread = fread(buf, SKIP_ONE, size, f);
(void)fclose(f);
if (nread > (size_t)size) {
nread = (size_t)size;
}
memset(buf + nread, 0, CBM_TS_LOOKAHEAD_PAD);
*out_len = (int)nread;
return buf;
}
/* Format int to string for logging. Thread-safe via TLS. */
static const char *itoa_log(int val) {
static CBM_TLS char bufs[PD_RING][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & PD_RING_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Append a JSON-escaped string value to buf at position *pos.
* Writes: ,"key":"escaped_value"
* Handles: \, ", \n, \r, \t */
static int def_json_escape_char(char *buf, size_t avail, char ch) {
char esc = 0;
switch (ch) {
case '"':
esc = '"';
break;
case '\\':
esc = '\\';
break;
case '\n':
esc = 'n';
break;
case '\r':
esc = 'r';
break;
case '\t':
esc = 't';
break;
default:
if (avail >= SKIP_ONE) {
/* Any other raw control byte (e.g. form feed) is invalid inside a
* JSON string — degrade to a space. */
buf[0] = ((unsigned char)ch < 0x20) ? ' ' : ch;
}
return SKIP_ONE;
}
if (avail >= PD_ESC_SPACE) {
buf[0] = '\\';
buf[SKIP_ONE] = esc;
}
return PD_ESC_SPACE;
}
/* Escaped length of a string under def_json_escape_char's rules: escaped
* characters expand to 2 bytes, everything else stays 1. */
static size_t def_json_escaped_len(const char *s) {
size_t n = 0;
for (; *s; s++) {
switch (*s) {
case '"':
case '\\':
case '\n':
case '\r':
case '\t':
n += PD_ESC_SPACE;
break;
default:
n += SKIP_ONE;
}
}
return n;
}
/* Appends are ATOMIC: a field is emitted only if the WHOLE serialized form
* fits (with PD_ESC_SPACE bytes reserved for the closing '}' + NUL). Cutting a
* field mid-value produced unterminated strings/arrays — malformed properties
* JSON that aborts every json_extract()-based consumer downstream (seen on the
* Linux kernel: 50-param functions truncated at the 2 KB cap). Dropping an
* oversized optional field whole keeps the JSON valid. */
static void append_json_string(char *buf, size_t bufsize, size_t *pos, const char *key,
const char *val) {
if (!val || val[0] == '\0') {
return;
}
/* ,"key":"<escaped>" — comma + 2 key quotes + colon + 2 value quotes */
size_t required = strlen(key) + def_json_escaped_len(val) + PD_JSON_FIELD_OVERHEAD;
if (*pos + required + PD_ESC_SPACE > bufsize) {
return; /* whole field would not fit — skip it atomically */
}
size_t p = *pos;
int w = snprintf(buf + p, bufsize - p, ",\"%s\":\"", key);
if (w <= 0 || (size_t)w >= bufsize - p) {
return;
}
p += (size_t)w;
for (const char *s = val; *s && p < bufsize - PD_ESC_MARGIN; s++) {
p += (size_t)def_json_escape_char(buf + p, bufsize - p - PD_ESC_SPACE, *s);
}
if (p < bufsize - SKIP_ONE) {
buf[p++] = '"';
}
buf[p] = '\0';
*pos = p;
}
/* Append a JSON array of strings: ,"key":["a","b","c"]. Atomic like
* append_json_string: emitted only if the whole array fits. */
static void append_json_str_array(char *buf, size_t bufsize, size_t *pos, const char *key,
const char **arr) {
if (!arr || !arr[0] || *pos >= bufsize - PD_JSON_MARGIN) {
return;
}
/* ,"key":[ + per item "<escaped>" + separating commas + ] */
size_t required = strlen(key) + PD_JSON_FIELD_OVERHEAD;
for (int i = 0; arr[i]; i++) {
required += def_json_escaped_len(arr[i]) + PD_ESC_SPACE + (i > 0 ? SKIP_ONE : 0);
}
if (*pos + required + PD_ESC_SPACE > bufsize) {
return; /* whole array would not fit — skip it atomically */
}
size_t p = *pos;
int n = snprintf(buf + p, bufsize - p, ",\"%s\":[", key);
if (n <= 0 || p + (size_t)n >= bufsize - PD_ESC_SPACE) {
return;
}
p += (size_t)n;
for (int i = 0; arr[i]; i++) {
if (i > 0 && p < bufsize - SKIP_ONE) {
buf[p++] = ',';
}
if (p < bufsize - SKIP_ONE) {
buf[p++] = '"';
}
/* Full escaping (not just quote/backslash): items like C param types
* sliced from multi-line declarations carry raw \n/\t bytes, which are
* invalid inside JSON strings. */
for (const char *s = arr[i]; *s && p < bufsize - PD_ESC_SPACE; s++) {
p += (size_t)def_json_escape_char(buf + p, bufsize - p - PD_ESC_SPACE, *s);
}
if (p < bufsize - SKIP_ONE) {
buf[p++] = '"';
}
}
if (p < bufsize - SKIP_ONE) {
buf[p++] = ']';
}
buf[p] = '\0';
*pos = p;
}
/* Build properties JSON for a definition node. */
static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) {
/* The complexity/loop/recursion metrics are only meaningful for executable
* units (Function/Method). Emitting them on the millions of Macro/Field/
* Variable/Class/Enum nodes — where they are always zero — bloats every
* node's properties (~150 B), inflating RAM, the gbuf merge copy and the
* dump. Gate the block to functions; other labels keep the lean base. */
const bool is_fn =
def->label && (strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0);
int n;
if (is_fn) {
n = snprintf(buf, bufsize,
"{\"complexity\":%d,\"cognitive\":%d,\"loop_count\":%d,\"loop_depth\":%d,"
"\"self_recursive\":%s,\"param_count\":%d,\"max_access_depth\":%d,"
"\"linear_scan_in_loop\":%d,\"alloc_in_loop\":%d,\"recursion_in_loop\":%s,"
"\"unguarded_recursion\":%s,"
"\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,\"is_entry_point\":%s",
def->complexity, def->cognitive, def->loop_count, def->loop_depth,
def->is_recursive ? "true" : "false", def->param_count, def->max_access_depth,
def->linear_scan_in_loop, def->alloc_in_loop,
def->recursion_in_loop ? "true" : "false",
def->unguarded_recursion ? "true" : "false", def->lines,
def->is_exported ? "true" : "false", def->is_test ? "true" : "false",
def->is_entry_point ? "true" : "false");
} else {
n = snprintf(buf, bufsize,
"{\"complexity\":%d,\"lines\":%d,\"is_exported\":%s,\"is_test\":%s,"
"\"is_entry_point\":%s",
def->complexity, def->lines, def->is_exported ? "true" : "false",
def->is_test ? "true" : "false", def->is_entry_point ? "true" : "false");
}
if (n <= 0 || (size_t)n >= bufsize) {
buf[0] = '\0';
return;
}
size_t pos = (size_t)n;
append_json_string(buf, bufsize, &pos, "docstring", def->docstring);
append_json_string(buf, bufsize, &pos, "signature", def->signature);
append_json_string(buf, bufsize, &pos, "return_type", def->return_type);
append_json_string(buf, bufsize, &pos, "parent_class", def->parent_class);
append_json_str_array(buf, bufsize, &pos, "decorators", def->decorators);
append_json_str_array(buf, bufsize, &pos, "base_classes", def->base_classes);
append_json_str_array(buf, bufsize, &pos, "param_names", def->param_names);
append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types);
append_json_string(buf, bufsize, &pos, "route_path", def->route_path);
append_json_string(buf, bufsize, &pos, "route_method", def->route_method);
/* MinHash fingerprint — append if present and buffer has room. */
if (def->fingerprint && def->fingerprint_k > 0 &&
pos + CBM_MINHASH_HEX_LEN + CBM_MINHASH_JSON_OVERHEAD < bufsize) {
char fp_hex[CBM_MINHASH_HEX_BUF];
cbm_minhash_to_hex((const cbm_minhash_t *)def->fingerprint, fp_hex, sizeof(fp_hex));
append_json_string(buf, bufsize, &pos, "fp", fp_hex);
}
/* AST structural profile */
if (def->structural_profile && pos + CBM_AST_PROFILE_BUF < bufsize) {
append_json_string(buf, bufsize, &pos, "sp", def->structural_profile);
}
/* Body tokens */
if (def->body_tokens && pos + CBM_SZ_512 < bufsize) {
append_json_string(buf, bufsize, &pos, "bt", def->body_tokens);
}
if (pos < bufsize - SKIP_ONE) {
buf[pos] = '}';
buf[pos + SKIP_ONE] = '\0';
}
}
/* Process one definition: create node, register, DEFINES + DEFINES_METHOD edges. */
static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel) {
if (!def->qualified_name || !def->name) {
return;
}
char props[CBM_SZ_2K];
build_def_props(props, sizeof(props), def);
int64_t node_id = cbm_gbuf_upsert_node(
ctx->gbuf, def->label ? def->label : "Function", def->name, def->qualified_name,
def->file_path ? def->file_path : rel, (int)def->start_line, (int)def->end_line, props);
/* Register callable symbols + every type-like container (Class/Struct/
* Interface/Enum/Type/Trait). Type-like defs must be in the registry so
* `class Foo : IBar` (INHERITS), `impl Trait for S` (IMPLEMENTS), and method/
* field resolution can reach them — Struct included so Rust/Go/Swift/D structs
* resolve as type targets just as a Class did. Variable/Field defs are also
* registered so pass_usages.c can resolve READS/WRITES accesses (rw->var_name)
* to a Variable/Field node QN.
* KEEP IN SYNC with pass_parallel.c and pipeline_incremental.c's seed sets. */
if (node_id > 0 && def->label &&
(strcmp(def->label, "Function") == 0 || strcmp(def->label, "Method") == 0 ||
cbm_label_is_type_like(def->label) || strcmp(def->label, "Variable") == 0 ||
strcmp(def->label, "Field") == 0)) {
cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label);
}
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
if (file_node && node_id > 0) {
cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, node_id, "DEFINES", "{}");
}
free(file_qn);
if (def->parent_class && def->label && strcmp(def->label, "Method") == 0) {
const cbm_gbuf_node_t *parent = cbm_gbuf_find_by_qn(ctx->gbuf, def->parent_class);
if (parent && node_id > 0) {
cbm_gbuf_insert_edge(ctx->gbuf, parent->id, node_id, "DEFINES_METHOD", "{}");
}
}
}
/* Create Channel nodes + EMITS / LISTENS_ON edges for one file's channels.
* Mirrors the parallel path in cbm_build_registry_from_cache — keep in sync. */
/* Find the source node for a channel edge: enclosing function or file node. */
static const cbm_gbuf_node_t *find_channel_source(cbm_pipeline_ctx_t *ctx, const CBMChannel *ch,
const char *rel) {
const cbm_gbuf_node_t *node = NULL;
if (ch->enclosing_func_qn && ch->enclosing_func_qn[0]) {
node = cbm_gbuf_find_by_qn(ctx->gbuf, ch->enclosing_func_qn);
}
if (!node) {
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
free(file_qn);
}
return node;
}
static void create_channel_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char *rel) {
for (int j = 0; j < result->channels.count; j++) {
const CBMChannel *ch = &result->channels.items[j];
if (!ch->channel_name || !ch->channel_name[0]) {
continue;
}
char channel_qn[CBM_SZ_512];
snprintf(channel_qn, sizeof(channel_qn), "__channel__%s__%s",
ch->transport ? ch->transport : "unknown", ch->channel_name);
char channel_props[CBM_SZ_512];
snprintf(channel_props, sizeof(channel_props), "{\"transport\":\"%s\",\"name\":\"%s\"}",
ch->transport ? ch->transport : "unknown", ch->channel_name);
int64_t channel_id = cbm_gbuf_upsert_node(ctx->gbuf, "Channel", ch->channel_name,
channel_qn, "", 0, 0, channel_props);
const cbm_gbuf_node_t *src_node = find_channel_source(ctx, ch, rel);
if (src_node && channel_id > 0) {
const char *edge_type = ch->direction == CBM_CHANNEL_EMIT ? "EMITS" : "LISTENS_ON";
char edge_props[CBM_SZ_128];
snprintf(edge_props, sizeof(edge_props), "{\"transport\":\"%s\"}",
ch->transport ? ch->transport : "unknown");
cbm_gbuf_insert_edge(ctx->gbuf, src_node->id, channel_id, edge_type, edge_props);
}
}
}
/* Create CONFIGURES edges for one file's env accesses. extract_env_accesses.c
* records every os.Getenv / process.env / Environment.GetEnvironmentVariable
* style access into result->env_accesses. We materialize one EnvVar node per
* env key and link the enclosing function (or the file node) CONFIGURES-> it,
* so environment-driven configuration is visible even when the accessor is a
* stdlib symbol that never resolves to an in-graph callee. */
static int create_env_configures_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char *rel) {
int count = 0;
char *file_qn = NULL;
const cbm_gbuf_node_t *file_node = NULL;
for (int j = 0; j < result->env_accesses.count; j++) {
const CBMEnvAccess *ea = &result->env_accesses.items[j];
if (!ea->env_key || !ea->env_key[0]) {
continue;
}
char env_qn[CBM_SZ_512];
snprintf(env_qn, sizeof(env_qn), "__env__%s", ea->env_key);
char env_props[CBM_SZ_512];
snprintf(env_props, sizeof(env_props), "{\"env_key\":\"%s\"}", ea->env_key);
int64_t env_id =
cbm_gbuf_upsert_node(ctx->gbuf, "EnvVar", ea->env_key, env_qn, "", 0, 0, env_props);
if (env_id <= 0) {
continue;
}
const cbm_gbuf_node_t *src = NULL;
if (ea->enclosing_func_qn && ea->enclosing_func_qn[0]) {
src = cbm_gbuf_find_by_qn(ctx->gbuf, ea->enclosing_func_qn);
/* A class-level env access in a directory-module language carries
* the DIRECTORY module QN, which hits the shared Folder/Project
* node — attribute to this file's File node instead (#787, #842). */
if (cbm_pipeline_node_is_dir_container(src)) {
src = NULL;
}
}
if (!src) {
if (!file_qn) {
file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
}
src = file_node;
}
if (src && src->id != env_id) {
cbm_gbuf_insert_edge(ctx->gbuf, src->id, env_id, "CONFIGURES",
"{\"strategy\":\"env_access\"}");
count++;
}
}
free(file_qn);
return count;
}
/* Create IMPORTS edges for one file's imports. Mirrors the resolution
* logic in pass_parallel.c register_and_link_def — keep the two in sync. */
static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char *rel, CBMHashTable *namespace_map) {
int count = 0;
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
const cbm_gbuf_node_t *source_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
if (!source_node) {
free(file_qn);
return 0;
}
for (int j = 0; j < result->imports.count; j++) {
const CBMImport *imp = &result->imports.items[j];
if (!imp->module_path) {
continue;
}
const cbm_gbuf_node_t *target =
cbm_pipeline_resolve_import_node(ctx, rel, file_qn, imp, namespace_map);
if (target && target->id != source_node->id) {
char imp_props[CBM_SZ_256];
snprintf(imp_props, sizeof(imp_props), "{\"local_name\":\"%s\"}",
imp->local_name ? imp->local_name : "");
cbm_gbuf_insert_edge(ctx->gbuf, source_node->id, target->id, "IMPORTS", imp_props);
count++;
}
}
free(file_qn);
return count;
}
int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count) {
cbm_log_info("pass.start", "pass", "definitions", "files", itoa_log(file_count));
/* Ensure extraction library is initialized */
cbm_init();
/* Defensive: a prior pipeline run may have left a thread-local parser whose
* lexer holds pointers into a slab that has since been reclaimed. Drop it
* here so the first cbm_extract_file below recreates a fresh parser. */
cbm_destroy_thread_parser();
int total_defs = 0;
int total_calls = 0;
int total_imports = 0;
int errors = 0;
/* Sequential pass must extract all defs (which create Module/Function/...
* nodes) BEFORE resolving imports — otherwise a workspace import in the
* first file processed can't find the target Module node, because the
* target file's defs haven't been extracted yet. Result cache is
* required for this two-phase ordering. */
CBMFileResult **local_cache = ctx->result_cache;
bool owns_local_cache = false;
if (!local_cache) {
local_cache = (CBMFileResult **)calloc((size_t)file_count, sizeof(CBMFileResult *));
owns_local_cache = (local_cache != NULL);
}
/* Phase 1: extract every file and create def-derived nodes (Modules,
* Functions, ...) so any file's IMPORTS can resolve against the
* complete in-memory graph in Phase 2. */
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
return CBM_NOT_FOUND;
}
const char *path = files[i].path;
const char *rel = files[i].rel_path;
CBMLanguage lang = files[i].language;
/* Crash-quarantine skip (Stage 3c): the supervisor's single-threaded
* recovery re-run always lands on THIS sequential path (worker_count
* forced to 1). This first sequential pass REPORTS a crasher as a
* phase="crash" skip (surfacing it in skipped[]) and continues; later
* sequential passes (calls/usages/semantic) re-extract on a cache miss
* but hit the hard guard inside cbm_extract_file, so they no-op without
* re-crashing and without duplicating the skip. No-op unless
* CBM_INDEX_QUARANTINE_FILE is set. */
if (cbm_index_is_quarantined(rel)) {
const char *phase = cbm_index_quarantine_phase(rel);
if (!phase) {
phase = "crash";
}
const char *reason =
(strcmp(phase, "hang") == 0) ? "quarantined after hang" : "quarantined after crash";
cbm_pipeline_add_file_error(ctx->pipeline, rel, reason, phase);
errors++;
continue;
}
/* Read source file */
int source_len = 0;
long file_size = 0;
cbm_read_status_t rst = CBM_READ_OK;
char *source = read_file(path, &source_len, &file_size, &rst);
if (!source) {
errors++;
if (rst == CBM_READ_OVERSIZED) {
/* Never a silent drop: record the oversized skip + WARN so the
* file surfaces in the response/logfile with its sizes. */
long cap = cbm_max_file_bytes();
char reason[96];
snprintf(reason, sizeof(reason), "oversized (%lld MB > %lld MB)",
(long long)(file_size / (CBM_SZ_1K * CBM_SZ_1K)),
(long long)(cap / (CBM_SZ_1K * CBM_SZ_1K)));
cbm_pipeline_add_file_error(ctx->pipeline, rel, reason, "oversized");
cbm_log_warn("index.file_oversized", "path", rel, "size_mb",
itoa_log((int)(file_size / (CBM_SZ_1K * CBM_SZ_1K))), "cap_mb",
itoa_log((int)(cap / (CBM_SZ_1K * CBM_SZ_1K))));
} else if (rst == CBM_READ_OPEN_FAIL || rst == CBM_READ_OOM) {
cbm_pipeline_add_file_error(ctx->pipeline, rel, "read failed", "read");
}
/* CBM_READ_EMPTY: benign 0-byte file — nothing to index, not reported. */
continue;
}
/* Extract */
CBMFileResult *result =
cbm_extract_file(source, source_len, lang, ctx->project_name, rel, CBM_EXTRACT_BUDGET,
NULL, NULL /* no extra defines or include paths */
);
free(source);
if (!result) {
errors++;
cbm_pipeline_add_file_error(ctx->pipeline, rel, "extract failed", "extract");
continue;
}
/* Consume the previously-ignored has_error flag: a parse timeout /
* parse failure / unsupported-grammar result carries no defs but must
* still be reported (phase "extract", reason = the extractor's message).
* The empty result flows through unchanged (the defs loop is a no-op). */
if (result->has_error) {
cbm_pipeline_add_file_error(ctx->pipeline, rel,
result->error_msg ? result->error_msg : "extract failed",
"extract");
errors++;
} else if (result->parse_incomplete) {
/* Best-effort parse-coverage signal (#963): indexed, but with
* ERROR/MISSING regions — see pass_parallel.c (keep in sync). */
cbm_pipeline_add_file_error(ctx->pipeline, rel,
result->error_ranges ? result->error_ranges : "unknown",
"parse_partial");
}
/* Create nodes for each definition */
for (int d = 0; d < result->defs.count; d++) {
process_def(ctx, &result->defs.items[d], rel);
total_defs++;
}
/* Store calls for pass_calls (we save them in the extraction results
* for now — a future optimization would batch these) */
total_calls += result->calls.count;
if (local_cache) {
local_cache[i] = result;
} else {
/* Cache unavailable: imports for this file can still only
* resolve to defs already in the graph, but the file's
* own defs are now persisted before the lookup. No namespace
* map is available without the cache (single-file scope). */
total_imports += create_import_edges_for_file(ctx, result, rel, NULL);
create_channel_edges_for_file(ctx, result, rel);
create_env_configures_for_file(ctx, result, rel);
cbm_free_result(result);
}
}
/* Phase 2: now that all extraction results are cached and Module
* nodes for every file are in the graph, walk the cache again to
* create IMPORTS / channel edges. Imports resolve against the full
* project graph. */
if (local_cache) {
/* Build a namespace/package → File-QN map so that namespace imports
* (C# `using`, Java/Kotlin `import`, PHP `use`) resolve to the file
* that declares the namespace. */
const char **rels = (const char **)calloc((size_t)file_count, sizeof(char *));
if (rels) {
for (int i = 0; i < file_count; i++) {
rels[i] = files[i].rel_path;
}
}
CBMHashTable *namespace_map =
cbm_pipeline_namespace_map_build(ctx->project_name, local_cache, rels, file_count);
free(rels);
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
break;
}
CBMFileResult *result = local_cache[i];
if (!result) {
continue;
}
total_imports +=
create_import_edges_for_file(ctx, result, files[i].rel_path, namespace_map);
create_channel_edges_for_file(ctx, result, files[i].rel_path);
create_env_configures_for_file(ctx, result, files[i].rel_path);
}
cbm_pipeline_namespace_map_free(namespace_map);
if (owns_local_cache) {
for (int i = 0; i < file_count; i++) {
if (local_cache[i]) {
cbm_free_result(local_cache[i]);
}
}
free(local_cache);
}
}
cbm_log_info("pass.done", "pass", "definitions", "defs", itoa_log(total_defs), "calls",
itoa_log(total_calls), "imports", itoa_log(total_imports), "errors",
itoa_log(errors));
return 0;
}
+412
View File
@@ -0,0 +1,412 @@
/*
* pass_enrichment.c — Decorator tokenization, camelCase splitting,
* and decorator_tags pass (post-flush enrichment).
*
* Pure helper functions + a store-level pass that classifies decorators
* into semantic tags via auto-discovery (words on 2+ nodes become tags).
*/
#include "foundation/constants.h"
enum { ENRICH_ATTR_SKIP = 2, ENRICH_MAX_CAMEL = 16 };
#include "pipeline/pipeline.h"
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/hash_table.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "yyjson/yyjson.h"
#include <ctype.h>
#include <stdlib.h>
#include <string.h>
/* Convert intptr_t to void* without triggering performance-no-int-to-ptr. */
static inline void *intptr_to_ptr(intptr_t v) {
void *p;
memcpy(&p, &v, sizeof(p));
return p;
}
static bool is_decorator_stopword(const char *w) {
static const char *stopwords[] = {"get", "set", "new", "class", "method", "function",
"value", "type", "param", "return", "public", "private",
"for", "if", "the", "and", "or", "not",
"with", "from", "app", "router", NULL};
for (int i = 0; stopwords[i]; i++) {
if (strcmp(w, stopwords[i]) == 0) {
return true;
}
}
return false;
}
int cbm_split_camel_case(const char *s, char **out, int max_out) {
if (!s || !out || max_out <= 0) {
return 0;
}
size_t len = strlen(s);
if (len == 0) {
return 0;
}
int count = 0;
size_t start = 0;
for (size_t i = SKIP_ONE; i < len; i++) {
if (s[i] >= 'A' && s[i] <= 'Z' && s[i - SKIP_ONE] >= 'a' && s[i - SKIP_ONE] <= 'z') {
if (count < max_out) {
out[count] = cbm_strndup(s + start, i - start);
count++;
}
start = i;
}
}
/* Emit remaining */
if (count < max_out) {
out[count] = cbm_strndup(s + start, len - start);
count++;
}
return count;
}
/* Strip decorator syntax: leading @, #[...], and arguments (...).
* Operates in-place on buf. Returns pointer to the cleaned token start. */
static char *strip_decorator_syntax(char *buf) {
char *p = buf;
if (*p == '@') {
p++;
}
if (p[0] == '#' && p[SKIP_ONE] == '[') {
p += ENRICH_ATTR_SKIP;
size_t plen = strlen(p);
if (plen > 0 && p[plen - SKIP_ONE] == ']') {
p[plen - SKIP_ONE] = '\0';
}
}
char *paren = strchr(p, '(');
if (paren) {
*paren = '\0';
}
for (char *c = p; *c; c++) {
if (*c == '.' || *c == '_' || *c == '-' || *c == ':' || *c == '/') {
*c = ' ';
}
}
return p;
}
int cbm_tokenize_decorator(const char *dec, char **out, int max_out) {
if (!dec || !out || max_out <= 0) {
return 0;
}
char buf[CBM_SZ_256];
size_t len = strlen(dec);
if (len >= sizeof(buf)) {
len = sizeof(buf) - SKIP_ONE;
}
memcpy(buf, dec, len);
buf[len] = '\0';
char *p = strip_decorator_syntax(buf);
int count = 0;
char *saveptr = NULL;
char *part = strtok_r(p, " ", &saveptr);
while (part && count < max_out) {
char *camel_parts[ENRICH_MAX_CAMEL];
int camel_count = cbm_split_camel_case(part, camel_parts, ENRICH_MAX_CAMEL);
for (int i = 0; i < camel_count && count < max_out; i++) {
for (char *c = camel_parts[i]; *c; c++) {
*c = (char)tolower((unsigned char)*c);
}
if (strlen(camel_parts[i]) >= PAIR_LEN && !is_decorator_stopword(camel_parts[i])) {
out[count++] = camel_parts[i];
} else {
free(camel_parts[i]);
}
}
part = strtok_r(NULL, " ", &saveptr);
}
return count;
}
/* ══════════════════════════════════════════════════════════════════
* Decorator Tags Pass (post-flush, operates on store)
*
* Algorithm:
* 1. Load all Function/Method/Class nodes from the store
* 2. For each node with "decorators" property, tokenize decorators
* 3. Count word frequency across all nodes
* 4. Words on 2+ distinct nodes become candidates
* 5. Update each node's properties_json with "decorator_tags" array
* ══════════════════════════════════════════════════════════════════ */
/* Per-node tokenization state */
typedef struct {
int64_t node_id;
char *qualified_name;
char **words;
int word_count;
} tagged_node_t;
/* Extract the "decorators" array from a properties_json string.
* Returns a NULL-terminated array of strings. Caller must free array and strings. */
static char **extract_decorators_from_json(const char *json) {
if (!json) {
return NULL;
}
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
if (!doc) {
return NULL;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *decs = yyjson_obj_get(root, "decorators");
if (!decs || !yyjson_is_arr(decs)) {
yyjson_doc_free(doc);
return NULL;
}
size_t cnt = yyjson_arr_size(decs);
if (cnt == 0) {
yyjson_doc_free(doc);
return NULL;
}
char **out = calloc(cnt + SKIP_ONE, sizeof(char *));
size_t idx = 0;
yyjson_val *item;
yyjson_arr_iter iter;
yyjson_arr_iter_init(decs, &iter);
while ((item = yyjson_arr_iter_next(&iter))) {
if (yyjson_is_str(item)) {
out[idx++] = strdup(yyjson_get_str(item));
}
}
out[idx] = NULL;
yyjson_doc_free(doc);
if (idx > 0) {
return out;
}
free(out);
return NULL;
}
/* Tokenize all decorators on a node into unique words.
* Returns heap-allocated array, caller frees each word and the array. */
static int extract_decorator_words(const char *json, char ***out_words) {
char **decorators = extract_decorators_from_json(json);
if (!decorators) {
*out_words = NULL;
return 0;
}
/* Collect unique words from all decorators */
char *all_words[CBM_SZ_256];
int total = 0;
CBMHashTable *seen = cbm_ht_create(CBM_SZ_32);
for (int i = 0; decorators[i]; i++) {
char *tokens[CBM_SZ_32];
int tc = cbm_tokenize_decorator(decorators[i], tokens, CBM_SZ_32);
for (int j = 0; j < tc; j++) {
if (!cbm_ht_get(seen, tokens[j]) && total < CBM_SZ_256) {
cbm_ht_set(seen, tokens[j], intptr_to_ptr(SKIP_ONE));
all_words[total++] = tokens[j];
} else {
free(tokens[j]);
}
}
free(decorators[i]);
}
free(decorators);
cbm_ht_free(seen);
if (total == 0) {
*out_words = NULL;
return 0;
}
*out_words = malloc(sizeof(char *) * total);
memcpy(*out_words, all_words, sizeof(char *) * total);
return total;
}
/* Insert "decorator_tags" into a properties_json string.
* Returns a newly allocated JSON string. Caller must free(). */
static char *inject_decorator_tags(const char *json, char **tags, int tag_count) {
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
yyjson_mut_doc *mdoc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *mroot;
if (root && yyjson_is_obj(root)) {
mroot = yyjson_val_mut_copy(mdoc, root);
} else {
mroot = yyjson_mut_obj(mdoc);
}
yyjson_mut_doc_set_root(mdoc, mroot);
/* Remove existing decorator_tags if any */
yyjson_mut_obj_remove_key(mroot, "decorator_tags");
/* Add sorted tag array */
yyjson_mut_val *arr = yyjson_mut_arr(mdoc);
for (int i = 0; i < tag_count; i++) {
yyjson_mut_arr_add_str(mdoc, arr, tags[i]);
}
yyjson_mut_obj_add_val(mdoc, mroot, "decorator_tags", arr);
char *result = yyjson_mut_write(mdoc, 0, NULL);
yyjson_mut_doc_free(mdoc);
yyjson_doc_free(doc);
return result;
}
/* Simple string comparison for qsort */
static int cmp_str(const void *a, const void *b) {
return strcmp(*(const char **)a, *(const char **)b);
}
/* Free tagged_node_t array. */
static void free_tagged_nodes(tagged_node_t *nodes, int count) {
for (int n = 0; n < count; n++) {
free(nodes[n].qualified_name);
for (int w = 0; w < nodes[n].word_count; w++) {
free(nodes[n].words[w]);
}
free(nodes[n].words);
}
free(nodes);
}
/* Phase 1: Collect decorated nodes and count word frequency. */
static int collect_decorated_nodes(cbm_gbuf_t *gbuf, tagged_node_t **out_nodes,
CBMHashTable *word_counts) {
/* "Struct" alongside "Class" so Go/Rust/Swift/D struct names keep
* contributing to / receiving auto-tags as they did when structs were
* labelled "Class". */
static const char *labels[] = {"Function", "Method", "Class", "Struct"};
static const int nlabels = 4;
tagged_node_t *nodes = NULL;
int node_count = 0;
int node_cap = 0;
for (int l = 0; l < nlabels; l++) {
const cbm_gbuf_node_t **found = NULL;
int fc = 0;
cbm_gbuf_find_by_label(gbuf, labels[l], &found, &fc);
if (!found || fc <= 0) {
continue;
}
for (int i = 0; i < fc; i++) {
char **words = NULL;
int wc = extract_decorator_words(found[i]->properties_json, &words);
if (wc <= 0) {
continue;
}
if (node_count >= node_cap) {
node_cap = node_cap ? node_cap * PAIR_LEN : CBM_SZ_64;
nodes = safe_realloc(nodes, sizeof(tagged_node_t) * node_cap);
}
tagged_node_t *tn = &nodes[node_count++];
tn->node_id = found[i]->id;
tn->qualified_name = strdup(found[i]->qualified_name);
tn->words = words;
tn->word_count = wc;
for (int w = 0; w < wc; w++) {
intptr_t cnt = (intptr_t)cbm_ht_get(word_counts, words[w]);
cbm_ht_set(word_counts, words[w], intptr_to_ptr(cnt + SKIP_ONE));
}
}
}
*out_nodes = nodes;
return node_count;
}
/* Phase 3: Apply candidate tags to nodes in the gbuf. */
static int apply_decorator_tags(cbm_gbuf_t *gbuf, tagged_node_t *nodes, int node_count,
CBMHashTable *candidates) {
int tagged = 0;
for (int n = 0; n < node_count; n++) {
char *tag_words[CBM_SZ_256];
int tag_count = 0;
for (int w = 0; w < nodes[n].word_count; w++) {
if (cbm_ht_get(candidates, nodes[n].words[w]) && tag_count < CBM_SZ_256) {
tag_words[tag_count++] = nodes[n].words[w];
}
}
if (tag_count == 0) {
continue;
}
qsort(tag_words, tag_count, sizeof(char *), cmp_str);
const cbm_gbuf_node_t *gn = cbm_gbuf_find_by_qn(gbuf, nodes[n].qualified_name);
if (!gn) {
continue;
}
const char *props = gn->properties_json ? gn->properties_json : "{}";
char *new_props = inject_decorator_tags(props, tag_words, tag_count);
if (new_props) {
cbm_gbuf_upsert_node(gbuf, gn->label, gn->name, gn->qualified_name, gn->file_path,
gn->start_line, gn->end_line, new_props);
free(new_props);
tagged++;
}
}
return tagged;
}
int cbm_pipeline_pass_decorator_tags(cbm_gbuf_t *gbuf, const char *project) {
if (!gbuf || !project) {
return 0;
}
CBMHashTable *word_counts = cbm_ht_create(CBM_SZ_128);
tagged_node_t *nodes = NULL;
int node_count = collect_decorated_nodes(gbuf, &nodes, word_counts);
if (node_count == 0) {
cbm_ht_free(word_counts);
free(nodes);
return 0;
}
/* Phase 2: Determine candidates (words on 2+ nodes) */
CBMHashTable *candidates = cbm_ht_create(CBM_SZ_64);
int candidate_count = 0;
for (int n = 0; n < node_count; n++) {
for (int w = 0; w < nodes[n].word_count; w++) {
const char *word = nodes[n].words[w];
intptr_t cnt = (intptr_t)cbm_ht_get(word_counts, word);
if (cnt >= PAIR_LEN && !cbm_ht_get(candidates, word)) {
cbm_ht_set(candidates, word, intptr_to_ptr(SKIP_ONE));
candidate_count++;
}
}
}
int tagged = 0;
if (candidate_count > 0) {
tagged = apply_decorator_tags(gbuf, nodes, node_count, candidates);
}
free_tagged_nodes(nodes, node_count);
cbm_ht_free(word_counts);
cbm_ht_free(candidates);
cbm_log_info("pass.decorator_tags", "candidates", candidate_count > 0 ? "yes" : "0", "tagged",
tagged > 0 ? "yes" : "0");
return tagged;
}
+433
View File
@@ -0,0 +1,433 @@
/*
* pass_envscan.c — Environment URL scanner.
*
* Walks a project directory, scans config files (Dockerfile, .env, shell,
* YAML, TOML, Terraform, .properties) for environment variable assignments
* where the value is a URL. Filters out secrets.
*
* Port of internal/pipeline/envscan.go:ScanProjectEnvURLs().
*/
#include "foundation/constants.h"
enum {
ENV_REGEX_MAX = 5,
ENV_GRP_1 = 1,
ENV_GRP_2 = 2,
ENV_GRP_3 = 3,
ENV_GRP_4 = 4,
ENV_GRP_5 = 5,
ENV_EXT_LEN = 4, /* strlen(".env") */
};
#define SLEN(s) (sizeof(s) - 1)
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "foundation/log.h"
#include <ctype.h>
#include "foundation/compat_fs.h"
#include "foundation/compat_regex.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
/* ── Regex patterns (compiled lazily) ──────────────────────────── */
static cbm_regex_t dockerfile_re; /* ENV|ARG KEY=VALUE or KEY VALUE */
static cbm_regex_t yaml_kv_re; /* key: "https://..." */
static cbm_regex_t yaml_setenv_re; /* --set-env-vars KEY=VALUE */
static cbm_regex_t terraform_re; /* default|value = "https://..." */
static cbm_regex_t shell_re; /* [export] KEY=https://... */
static cbm_regex_t envfile_re; /* KEY=https://... */
static cbm_regex_t toml_re; /* key = "https://..." */
static cbm_regex_t properties_re; /* key=https://... */
static int patterns_compiled = 0;
/* POSIX ERE doesn't support \w or \S — use bracket expressions */
#define W "[A-Za-z0-9_]" /* word char */
#define NW "[^ \t\"']" /* non-whitespace, non-quote */
static void compile_patterns(void) {
if (patterns_compiled) {
return;
}
cbm_regcomp(&dockerfile_re, "^(ENV|ARG)[[:space:]]+(" W "+)[= ](.*)", CBM_REG_EXTENDED);
cbm_regcomp(&yaml_kv_re, "(" W "+):[[:space:]]*[\"']?(https?://" NW "+)", CBM_REG_EXTENDED);
cbm_regcomp(&yaml_setenv_re, "--set-env-vars[[:space:]]+(" W "+)=([^ \t]+)", CBM_REG_EXTENDED);
cbm_regcomp(&terraform_re, "(default|value)[[:space:]]*=[[:space:]]*\"(https?://[^\"]+)\"",
CBM_REG_EXTENDED);
cbm_regcomp(&shell_re, "(export[[:space:]]+)?(" W "+)=[\"']?(https?://" NW "+)",
CBM_REG_EXTENDED);
cbm_regcomp(&envfile_re, "^(" W "+)=(https?://[^ \t]+)", CBM_REG_EXTENDED);
cbm_regcomp(&toml_re, "(" W "+)[[:space:]]*=[[:space:]]*\"(https?://[^\"]+)\"",
CBM_REG_EXTENDED);
cbm_regcomp(&properties_re, "(" W "+)[[:space:]]*=[[:space:]]*(https?://[^ \t]+)",
CBM_REG_EXTENDED);
patterns_compiled = SKIP_ONE;
}
#undef W
#undef NW
/* ── File type detection ───────────────────────────────────────── */
static int is_dockerfile_name(const char *name) {
/* Case-insensitive check */
char lower[CBM_SZ_256];
size_t len = strlen(name);
if (len >= sizeof(lower)) {
return 0;
}
for (size_t i = 0; i <= len; i++) {
lower[i] = (char)tolower((unsigned char)name[i]);
}
if (strcmp(lower, "dockerfile") == 0) {
return SKIP_ONE;
}
#define DOCKERFILE_SUFFIX_LEN 11 /* strlen("dockerfile.") == strlen(".dockerfile") */
if (strncmp(lower, "dockerfile.", DOCKERFILE_SUFFIX_LEN) == 0) {
return SKIP_ONE;
}
if (len > DOCKERFILE_SUFFIX_LEN &&
strcmp(lower + len - DOCKERFILE_SUFFIX_LEN, ".dockerfile") == 0) {
return SKIP_ONE;
}
return 0;
}
static int is_env_file_name(const char *name) {
char lower[CBM_SZ_256];
size_t len = strlen(name);
if (len >= sizeof(lower)) {
return 0;
}
for (size_t i = 0; i <= len; i++) {
lower[i] = (char)tolower((unsigned char)name[i]);
}
if (strcmp(lower, ".env") == 0) {
return SKIP_ONE;
}
if (strncmp(lower, ".env.", SLEN(".env.")) == 0) {
return SKIP_ONE;
}
if (len > ENV_EXT_LEN && strcmp(lower + len - ENV_EXT_LEN, ".env") == 0) {
return SKIP_ONE;
}
return 0;
}
static int is_secret_file(const char *name) {
char lower[CBM_SZ_256];
size_t len = strlen(name);
if (len >= sizeof(lower)) {
return 0;
}
for (size_t i = 0; i <= len; i++) {
lower[i] = (char)tolower((unsigned char)name[i]);
}
static const char *patterns[] = {
"service_account", "credentials", "key.json", "key.pem", "id_rsa",
"id_ed25519", ".pem", ".key", NULL};
for (int i = 0; patterns[i]; i++) {
if (strstr(lower, patterns[i])) {
return SKIP_ONE;
}
}
return 0;
}
/* ── Ignored directories ───────────────────────────────────────── */
static int is_ignored_dir(const char *name) {
static const char *dirs[] = {
".git", "node_modules", ".svn", ".hg", "__pycache__", "vendor", ".terraform", ".cache",
".idea", ".vscode", "dist", "build", ".next", ".nuxt", "target", NULL};
for (int i = 0; dirs[i]; i++) {
if (strcmp(name, dirs[i]) == 0) {
return SKIP_ONE;
}
}
return 0;
}
/* ── File type enum ────────────────────────────────────────────── */
typedef enum {
FT_UNKNOWN = 0,
FT_DOCKERFILE,
FT_YAML,
FT_TERRAFORM,
FT_SHELL,
FT_ENVFILE,
FT_TOML,
FT_PROPERTIES,
} file_type_t;
static file_type_t detect_file_type(const char *name) {
if (is_dockerfile_name(name)) {
return FT_DOCKERFILE;
}
if (is_env_file_name(name)) {
return FT_ENVFILE;
}
const char *ext = strrchr(name, '.');
if (!ext) {
return FT_UNKNOWN;
}
if (strcmp(ext, ".yaml") == 0 || strcmp(ext, ".yml") == 0) {
return FT_YAML;
}
if (strcmp(ext, ".tf") == 0 || strcmp(ext, ".hcl") == 0) {
return FT_TERRAFORM;
}
if (strcmp(ext, ".sh") == 0 || strcmp(ext, ".bash") == 0 || strcmp(ext, ".zsh") == 0) {
return FT_SHELL;
}
if (strcmp(ext, ".toml") == 0) {
return FT_TOML;
}
if (strcmp(ext, ".properties") == 0 || strcmp(ext, ".cfg") == 0 || strcmp(ext, ".ini") == 0) {
return FT_PROPERTIES;
}
return FT_UNKNOWN;
}
/* ── Line scanner ──────────────────────────────────────────────── */
/* Extract key/value from a regex match with two capture groups.
* Returns 1 on success, 0 if groups are empty or too large. */
static int extract_kv_groups(const char *trimmed, const cbm_regmatch_t *m, int key_grp, int val_grp,
char *key_out, size_t key_sz, char *val_out, size_t val_sz) {
int klen = (m[key_grp].rm_eo - m[key_grp].rm_so);
int vlen = (m[val_grp].rm_eo - m[val_grp].rm_so);
if (klen <= 0 || klen >= (int)key_sz || vlen <= 0 || vlen >= (int)val_sz) {
return 0;
}
memcpy(key_out, trimmed + m[key_grp].rm_so, klen);
key_out[klen] = '\0';
memcpy(val_out, trimmed + m[val_grp].rm_so, vlen);
val_out[vlen] = '\0';
return SKIP_ONE;
}
/* Try to scan a Dockerfile line. */
static int scan_dockerfile_line(const char *line, char *key, size_t ksz, char *val, size_t vsz) {
cbm_regmatch_t m[ENV_REGEX_MAX];
if (cbm_regexec(&dockerfile_re, line, ENV_GRP_4, m, 0) != 0) {
return 0;
}
if (!extract_kv_groups(line, m, ENV_GRP_2, ENV_GRP_3, key, ksz, val, vsz)) {
return 0;
}
size_t vl = strlen(val);
while (vl > 0 && (val[vl - SKIP_ONE] == '"' || val[vl - SKIP_ONE] == '\'')) {
val[--vl] = '\0';
}
return SKIP_ONE;
}
/* Try to scan a YAML line. */
static int scan_yaml_line(const char *line, char *key, size_t ksz, char *val, size_t vsz) {
cbm_regmatch_t m[ENV_REGEX_MAX];
if (cbm_regexec(&yaml_kv_re, line, ENV_GRP_3, m, 0) == 0 &&
extract_kv_groups(line, m, ENV_GRP_1, ENV_GRP_2, key, ksz, val, vsz)) {
return SKIP_ONE;
}
if (cbm_regexec(&yaml_setenv_re, line, ENV_GRP_3, m, 0) == 0 &&
extract_kv_groups(line, m, ENV_GRP_1, ENV_GRP_2, key, ksz, val, vsz)) {
return SKIP_ONE;
}
return 0;
}
/* Try to scan a Terraform line. */
static int scan_terraform_line(const char *line, char *key, size_t ksz, char *val, size_t vsz) {
cbm_regmatch_t m[ENV_REGEX_MAX];
if (cbm_regexec(&terraform_re, line, ENV_GRP_3, m, 0) != 0) {
return 0;
}
int vlen = (m[ENV_GRP_2].rm_eo - m[ENV_GRP_2].rm_so);
if (vlen <= 0 || vlen >= (int)vsz) {
return 0;
}
strncpy(key, "_tf_default", ksz - SKIP_ONE);
key[ksz - SKIP_ONE] = '\0';
memcpy(val, line + m[ENV_GRP_2].rm_so, vlen);
val[vlen] = '\0';
return SKIP_ONE;
}
/* Try single-regex scan (shell, envfile, toml, properties). */
static int scan_regex_line(cbm_regex_t *re, const char *line, int kg, int vg, char *key, size_t ksz,
char *val, size_t vsz) {
cbm_regmatch_t m[ENV_REGEX_MAX];
if (cbm_regexec(re, line, ENV_GRP_5, m, 0) == 0 &&
extract_kv_groups(line, m, kg, vg, key, ksz, val, vsz)) {
return SKIP_ONE;
}
return 0;
}
static int scan_line(const char *line, file_type_t ft, char *key_out, size_t key_sz, char *val_out,
size_t val_sz) {
const char *trimmed = line;
while (*trimmed == ' ' || *trimmed == '\t') {
trimmed++;
}
if (*trimmed == '#' || (trimmed[0] == '/' && trimmed[SKIP_ONE] == '/')) {
return 0;
}
switch (ft) {
case FT_DOCKERFILE:
return scan_dockerfile_line(trimmed, key_out, key_sz, val_out, val_sz);
case FT_YAML:
return scan_yaml_line(trimmed, key_out, key_sz, val_out, val_sz);
case FT_TERRAFORM:
return scan_terraform_line(trimmed, key_out, key_sz, val_out, val_sz);
case FT_SHELL:
return scan_regex_line(&shell_re, trimmed, ENV_GRP_2, ENV_GRP_3, key_out, key_sz, val_out,
val_sz);
case FT_ENVFILE:
return scan_regex_line(&envfile_re, trimmed, ENV_GRP_1, ENV_GRP_2, key_out, key_sz, val_out,
val_sz);
case FT_TOML:
return scan_regex_line(&toml_re, trimmed, ENV_GRP_1, ENV_GRP_2, key_out, key_sz, val_out,
val_sz);
case FT_PROPERTIES:
return scan_regex_line(&properties_re, trimmed, ENV_GRP_1, ENV_GRP_2, key_out, key_sz,
val_out, val_sz);
default:
return 0;
}
}
/* ── Public API ────────────────────────────────────────────────── */
/* Scan a single file for env URL bindings. Returns number of bindings added. */
static int scan_env_file(const char *full_path, const char *rel, file_type_t ft,
cbm_env_binding_t *out, int max_out) {
FILE *f = cbm_fopen(full_path, "r");
if (!f) {
return 0;
}
struct stat fst;
if (fstat(fileno(f), &fst) != 0 || fst.st_size > (long)CBM_SZ_1K * CBM_SZ_1K) {
(void)fclose(f);
return 0;
}
int count = 0;
char line[CBM_SZ_2K];
while (fgets(line, sizeof(line), f) && count < max_out) {
size_t ll = strlen(line);
while (ll > 0 && (line[ll - SKIP_ONE] == '\n' || line[ll - SKIP_ONE] == '\r')) {
line[--ll] = '\0';
}
char key[CBM_SZ_128];
char value[CBM_SZ_512];
if (!scan_line(line, ft, key, sizeof(key), value, sizeof(value))) {
continue;
}
if (strncmp(value, "http://", SLEN("http://")) != 0 &&
strncmp(value, "https://", SLEN("https://")) != 0) {
continue;
}
if (cbm_is_secret_binding(key, value) || cbm_is_secret_value(value)) {
continue;
}
strncpy(out[count].key, key, sizeof(out[count].key) - 1);
out[count].key[sizeof(out[count].key) - SKIP_ONE] = '\0';
strncpy(out[count].value, value, sizeof(out[count].value) - 1);
out[count].value[sizeof(out[count].value) - SKIP_ONE] = '\0';
strncpy(out[count].file_path, rel, sizeof(out[count].file_path) - 1);
out[count].file_path[sizeof(out[count].file_path) - SKIP_ONE] = '\0';
count++;
}
(void)fclose(f);
return count;
}
/* Process a single directory entry for env scanning. Returns bindings added. */
static int process_env_entry(cbm_dirent_t *ent, const char *dir_path, const char *root_path,
cbm_env_binding_t *out, int max_out, char path_stack[][CBM_SZ_512],
int *stack_top, char **excluded_dirs, int excluded_count) {
char full_path[CBM_SZ_512];
snprintf(full_path, sizeof(full_path), "%s/%s", dir_path, ent->name);
const char *rel = full_path + strlen(root_path);
while (*rel == '/') {
rel++;
}
struct stat st;
if (stat(full_path, &st) != 0) {
return 0;
}
if (S_ISDIR(st.st_mode)) {
if (!is_ignored_dir(ent->name) &&
!cbm_pipeline_relpath_is_excluded(rel, excluded_dirs, excluded_count) &&
*stack_top < CBM_SZ_256) {
strncpy(path_stack[*stack_top], full_path, sizeof(path_stack[0]) - 1);
path_stack[*stack_top][sizeof(path_stack[0]) - SKIP_ONE] = '\0';
(*stack_top)++;
}
return 0;
}
if (is_secret_file(ent->name)) {
return 0;
}
file_type_t ft = detect_file_type(ent->name);
if (ft == FT_UNKNOWN) {
return 0;
}
return scan_env_file(full_path, rel, ft, out, max_out);
}
int cbm_scan_project_env_urls_excluded(const char *root_path, cbm_env_binding_t *out, int max_out,
char **excluded_dirs, int excluded_count) {
if (!root_path || !out || max_out <= 0) {
return 0;
}
compile_patterns();
int count = 0;
char path_stack[CBM_SZ_256][CBM_SZ_512];
int stack_top = SKIP_ONE;
strncpy(path_stack[0], root_path, sizeof(path_stack[0]) - 1);
path_stack[0][sizeof(path_stack[0]) - SKIP_ONE] = '\0';
while (stack_top > 0 && count < max_out) {
stack_top--;
char dir_path[CBM_SZ_512];
strncpy(dir_path, path_stack[stack_top], sizeof(dir_path) - SKIP_ONE);
dir_path[sizeof(dir_path) - SKIP_ONE] = '\0';
cbm_dir_t *d = cbm_opendir(dir_path);
if (!d) {
continue;
}
cbm_dirent_t *ent;
while ((ent = cbm_readdir(d)) && count < max_out) {
count += process_env_entry(ent, dir_path, root_path, out + count, max_out - count,
path_stack, &stack_top, excluded_dirs, excluded_count);
}
cbm_closedir(d);
}
return count;
}
int cbm_scan_project_env_urls(const char *root_path, cbm_env_binding_t *out, int max_out) {
return cbm_scan_project_env_urls_excluded(root_path, out, max_out, NULL, 0);
}
+190
View File
@@ -0,0 +1,190 @@
/*
* pass_gitdiff.c — Git diff output parsing helpers.
*
* Pure string parsers for git diff --name-status and --unified=0 output.
* No git execution — just parsing pre-captured output strings.
*/
#include "foundation/constants.h"
enum { GD_STATUS_IDX = 1, GD_PLUS_PREFIX = 6 };
#define SLEN(s) (sizeof(s) - 1)
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void cbm_parse_range(const char *s, int *out_start, int *out_count) {
*out_start = 0;
*out_count = SKIP_ONE;
const char *comma = strchr(s, ',');
if (!comma) {
*out_start = (int)strtol(s, NULL, CBM_DECIMAL_BASE);
return;
}
/* Parse start */
char buf[CBM_SZ_32];
size_t len = (size_t)(comma - s);
if (len >= sizeof(buf)) {
len = sizeof(buf) - SKIP_ONE;
}
memcpy(buf, s, len);
buf[len] = '\0';
*out_start = (int)strtol(buf, NULL, CBM_DECIMAL_BASE);
/* Parse count */
*out_count = (int)strtol(comma + SKIP_ONE, NULL, CBM_DECIMAL_BASE);
}
#define HUNK_LINE_BUF 1536
/* Parse a single name-status line into a cbm_changed_file_t.
* Returns true if a valid entry was produced. */
static bool parse_one_name_status(const char *line, size_t line_len, cbm_changed_file_t *out_f) {
char tmp[HUNK_LINE_BUF];
if (line_len >= sizeof(tmp)) {
line_len = sizeof(tmp) - SKIP_ONE;
}
memcpy(tmp, line, line_len);
tmp[line_len] = '\0';
char *status_str = tmp;
char *tab1 = strchr(tmp, '\t');
if (!tab1) {
return false;
}
*tab1 = '\0';
char *path1 = tab1 + SKIP_ONE;
char *tab2 = strchr(path1, '\t');
char *path2 = NULL;
if (tab2) {
*tab2 = '\0';
path2 = tab2 + SKIP_ONE;
}
memset(out_f, 0, sizeof(*out_f));
if (status_str[0] == 'R') {
out_f->status[0] = 'R';
out_f->status[GD_STATUS_IDX] = '\0';
snprintf(out_f->old_path, sizeof(out_f->old_path), "%s", path1);
snprintf(out_f->path, sizeof(out_f->path), "%s", path2 ? path2 : path1);
} else {
out_f->status[0] = status_str[0];
out_f->status[GD_STATUS_IDX] = '\0';
snprintf(out_f->path, sizeof(out_f->path), "%s", path1);
out_f->old_path[0] = '\0';
}
return cbm_is_trackable_file(out_f->path);
}
int cbm_parse_name_status(const char *output, cbm_changed_file_t *out, int max_out) {
if (!output || !out || max_out <= 0) {
return 0;
}
int count = 0;
const char *line = output;
while (*line && count < max_out) {
const char *eol = strchr(line, '\n');
size_t line_len = eol ? (size_t)(eol - line) : strlen(line);
if (line_len > 0) {
cbm_changed_file_t f;
if (parse_one_name_status(line, line_len, &f)) {
out[count++] = f;
}
}
line = eol ? eol + SKIP_ONE : line + line_len;
}
return count;
}
/* Parse a single @@ hunk header line and emit a hunk entry if valid.
* Returns true if a hunk was added. */
static bool parse_hunk_line(const char *line, size_t line_len, const char *current_file,
cbm_changed_hunk_t *out_h) {
const char *plus = memchr(line, '+', line_len);
if (!plus || plus <= line) {
return false;
}
const char *end_at = strstr(plus, " @@");
size_t range_len;
if (end_at) {
range_len = (size_t)(end_at - plus - SKIP_ONE);
} else {
range_len = (size_t)(line + line_len - plus - SKIP_ONE);
}
char range_str[CBM_SZ_64];
if (range_len >= sizeof(range_str)) {
range_len = sizeof(range_str) - SKIP_ONE;
}
memcpy(range_str, plus + SKIP_ONE, range_len);
range_str[range_len] = '\0';
int start;
int cnt;
cbm_parse_range(range_str, &start, &cnt);
if (start <= 0 || !cbm_is_trackable_file(current_file)) {
return false;
}
int end = start + cnt - SKIP_ONE;
if (end < start) {
end = start;
}
snprintf(out_h->path, sizeof(out_h->path), "%s", current_file);
out_h->start_line = start;
out_h->end_line = end;
return true;
}
int cbm_parse_hunks(const char *output, cbm_changed_hunk_t *out, int max_out) {
if (!output || !out || max_out <= 0) {
return 0;
}
int count = 0;
char current_file[CBM_SZ_512] = {0};
const char *line = output;
while (*line && count < max_out) {
const char *eol = strchr(line, '\n');
size_t line_len = eol ? (size_t)(eol - line) : strlen(line);
if (line_len == 0) {
line = eol ? eol + SKIP_ONE : line + line_len;
continue;
}
if (line_len > GD_PLUS_PREFIX && strncmp(line, "+++ b/", SLEN("+++ b/")) == 0) {
size_t flen = line_len - GD_PLUS_PREFIX;
if (flen >= sizeof(current_file)) {
flen = sizeof(current_file) - SKIP_ONE;
}
memcpy(current_file, line + 6, flen);
current_file[flen] = '\0';
} else if (line_len >= PAIR_LEN && line[0] == '@' && line[GD_STATUS_IDX] == '@' &&
current_file[0]) {
cbm_changed_hunk_t h;
if (parse_hunk_line(line, line_len, current_file, &h)) {
out[count++] = h;
}
}
line = eol ? eol + SKIP_ONE : line + line_len;
}
return count;
}
+514
View File
@@ -0,0 +1,514 @@
/*
* pass_githistory.c — Analyze git log to find change coupling.
*
* Runs `git log --name-only --since=6 months ago` and computes
* file pairs that change together frequently. Creates FILE_CHANGES_WITH
* edges between File nodes with coupling_score properties.
*
* Skips commits with >20 files (refactoring/merge noise).
* Requires minimum 3 co-changes for an edge.
*
* Depends on: pass_structure having created File nodes
*/
#include "foundation/constants.h"
enum { GH_RING = 4, GH_RING_MASK = 3, GH_INIT_CAP = 16, GH_MIN_COMMITS = 3, GH_MAX_FILES = 20 };
#define SLEN(s) (sizeof(s) - 1)
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/hash_table.h"
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/str_util.h"
/* Minimum coupling score to create an edge */
#define MIN_COUPLING_SCORE 0.3
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *itoa_log(int val) {
static CBM_TLS char bufs[GH_RING][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & GH_RING_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
static bool ends_with(const char *s, size_t slen, const char *suffix) {
size_t sflen = strlen(suffix);
return slen >= sflen && strcmp(s + slen - sflen, suffix) == 0;
}
bool cbm_is_trackable_file(const char *path) {
if (!path) {
return false;
}
/* Skip directory prefixes */
#define LEN_NODE_MODULES_SLASH 13 /* strlen("node_modules/") */
if (strncmp(path, ".git/", SLEN(".git/")) == 0 ||
strncmp(path, "node_modules/", LEN_NODE_MODULES_SLASH) == 0 ||
strncmp(path, "vendor/", SLEN("vendor/")) == 0 ||
strncmp(path, "__pycache__/", SLEN("__pycache__/")) == 0 ||
strncmp(path, ".cache/", SLEN(".cache/")) == 0) {
return false;
}
/* Skip lock/generated file names */
const char *base = strrchr(path, '/');
base = base ? base + SKIP_ONE : path;
if (strcmp(base, "package-lock.json") == 0 || strcmp(base, "yarn.lock") == 0 ||
strcmp(base, "pnpm-lock.yaml") == 0 || strcmp(base, "Cargo.lock") == 0 ||
strcmp(base, "poetry.lock") == 0 || strcmp(base, "composer.lock") == 0 ||
strcmp(base, "Gemfile.lock") == 0 || strcmp(base, "Pipfile.lock") == 0) {
return false;
}
/* Skip non-source file extensions */
size_t len = strlen(path);
if (ends_with(path, len, ".lock") || ends_with(path, len, ".sum") ||
ends_with(path, len, ".min.js") || ends_with(path, len, ".min.css") ||
ends_with(path, len, ".map") || ends_with(path, len, ".wasm") ||
ends_with(path, len, ".png") || ends_with(path, len, ".jpg") ||
ends_with(path, len, ".gif") || ends_with(path, len, ".ico") ||
ends_with(path, len, ".svg")) {
return false;
}
return true;
}
/* ── Commit parsing ───────────────────────────────────────────────── */
typedef struct {
char **files;
int count;
int cap;
long long timestamp; /* unix epoch of this commit; 0 when unknown */
} commit_t;
static void commit_add_file(commit_t *c, const char *file) {
if (c->count >= c->cap) {
c->cap = c->cap ? c->cap * PAIR_LEN : GH_INIT_CAP;
c->files = safe_realloc(c->files, c->cap * sizeof(char *));
}
c->files[c->count++] = strdup(file);
}
static void commit_free(commit_t *c) {
for (int i = 0; i < c->count; i++) {
free(c->files[i]);
}
free(c->files);
}
/* ── git log parsing (popen "git log") ────────────────────────────── */
static int parse_git_log(const char *repo_path, commit_t **out, int *out_count) {
*out = NULL;
*out_count = 0;
if (!cbm_validate_shell_arg(repo_path)) {
return CBM_NOT_FOUND;
}
char cmd[CBM_SZ_1K];
#ifdef _WIN32
/* cmd.exe does not recognize single quotes, and '/dev/null' is a POSIX path. */
const char *null_dev = "NUL";
#else
const char *null_dev = "/dev/null";
#endif
/* git -C "<path>" works on both cmd.exe and POSIX shells. Double quotes are
* safe here because cbm_validate_shell_arg (above) rejects ", $, `, \ and the
* other shell metacharacters that would otherwise be active inside them. */
snprintf(cmd, sizeof(cmd),
"git -C \"%s\" log --name-only --pretty=format:COMMIT:%%H:%%ct "
"--since=\"1 year ago\" --max-count=10000 2>%s",
repo_path, null_dev);
FILE *fp = cbm_popen(cmd, "r");
if (!fp) {
return CBM_NOT_FOUND;
}
int cap = CBM_SZ_64;
commit_t *commits = malloc(cap * sizeof(commit_t));
int count = 0;
commit_t current = {0};
char line[CBM_SZ_1K];
while (fgets(line, sizeof(line), fp)) {
size_t len = strlen(line);
while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) {
line[--len] = '\0';
}
if (len == 0) {
continue;
}
if (strncmp(line, "COMMIT:", SLEN("COMMIT:")) == 0) {
if (current.count > 0) {
if (count >= cap) {
cap *= PAIR_LEN;
commits = safe_realloc(commits, cap * sizeof(commit_t));
}
commits[count++] = current;
memset(&current, 0, sizeof(current));
}
/* Parse the unix timestamp from "COMMIT:<hash>:<unix_epoch>".
* Older callers / stripped-down git output without %ct land on 0. */
const char *hash_end = strchr(line + SLEN("COMMIT:"), ':');
if (hash_end) {
current.timestamp = strtoll(hash_end + 1, NULL, 10);
}
continue;
}
if (cbm_is_trackable_file(line)) {
commit_add_file(&current, line);
}
}
if (current.count > 0) {
if (count >= cap) {
cap *= PAIR_LEN;
commits = safe_realloc(commits, cap * sizeof(commit_t));
}
commits[count++] = current;
} else {
commit_free(&current);
}
cbm_pclose(fp);
*out = commits;
*out_count = count;
return 0;
}
/* Callback to free hash table entries. */
static void free_counter(const char *key, void *val, void *ud) {
(void)ud;
safe_str_free(&key);
free(val);
}
/* ── Standalone coupling computation (testable) ──────────────────── */
/* Context for collect_coupling_result callback. */
typedef struct {
CBMHashTable *file_counts;
CBMHashTable *pair_timestamps; /* pair_key → long long*: max commit ts */
cbm_change_coupling_t *out;
int out_count;
int max_out;
} collect_coupling_ctx_t;
static void collect_coupling_cb(const char *pair_key, void *val, void *ud) {
collect_coupling_ctx_t *cctx = ud;
int co_count = *(int *)val;
if (co_count < GH_MIN_COMMITS) {
return;
}
if (cctx->out_count >= cctx->max_out) {
return;
}
const char *sep = strchr(pair_key, '\x01');
if (!sep) {
return;
}
size_t la = sep - pair_key;
const char *file_b = sep + SKIP_ONE;
char file_a_buf[CBM_SZ_512];
if (la >= sizeof(file_a_buf)) {
return;
}
memcpy(file_a_buf, pair_key, la);
file_a_buf[la] = '\0';
int *count_a = cbm_ht_get(cctx->file_counts, file_a_buf);
int *count_b = cbm_ht_get(cctx->file_counts, file_b);
if (!count_a || !count_b) {
return;
}
int min_total = *count_a < *count_b ? *count_a : *count_b;
if (min_total == 0) {
return;
}
double score = (double)co_count / (double)min_total;
if (score < MIN_COUPLING_SCORE) {
return;
}
cbm_change_coupling_t *cc = &cctx->out[cctx->out_count++];
snprintf(cc->file_a, sizeof(cc->file_a), "%s", file_a_buf);
snprintf(cc->file_b, sizeof(cc->file_b), "%s", file_b);
cc->co_change_count = co_count;
cc->coupling_score = score;
long long *ts = cbm_ht_get(cctx->pair_timestamps, pair_key);
cc->last_co_change = ts ? *ts : 0;
}
int cbm_compute_change_coupling(const cbm_commit_files_t *commits, int commit_count,
cbm_change_coupling_t *out, int max_out) {
CBMHashTable *file_counts = cbm_ht_create(CBM_SZ_1K);
CBMHashTable *pair_counts = cbm_ht_create(CBM_SZ_2K);
/* Parallel table mapping pair_key → max commit timestamp seen for that
* pair, so the resulting edge can carry last_co_change. The pair_counts
* table consumes its key on insert; pair_timestamps gets its own copy. */
CBMHashTable *pair_timestamps = cbm_ht_create(CBM_SZ_2K);
for (int c = 0; c < commit_count; c++) {
if (commits[c].count > GH_MAX_FILES) {
continue;
}
for (int i = 0; i < commits[c].count; i++) {
int *val = cbm_ht_get(file_counts, commits[c].files[i]);
if (val) {
(*val)++;
} else {
int *nv = malloc(sizeof(int));
*nv = SKIP_ONE;
cbm_ht_set(file_counts, strdup(commits[c].files[i]), nv);
}
}
for (int i = 0; i < commits[c].count; i++) {
for (int j = i + SKIP_ONE; j < commits[c].count; j++) {
const char *a = commits[c].files[i];
const char *b = commits[c].files[j];
if (strcmp(a, b) > 0) {
const char *t = a;
a = b;
b = t;
}
size_t la = strlen(a);
size_t lb = strlen(b);
size_t pk_len = la + SKIP_ONE + lb + SKIP_ONE;
char *pk = malloc(pk_len);
memcpy(pk, a, la);
pk[la] = '\x01';
memcpy(pk + la + SKIP_ONE, b, lb + SKIP_ONE);
int *val = cbm_ht_get(pair_counts, pk);
if (val) {
(*val)++;
long long *ts = cbm_ht_get(pair_timestamps, pk);
if (ts && commits[c].timestamp > *ts) {
*ts = commits[c].timestamp;
}
free(pk);
} else {
int *nv = malloc(sizeof(int));
*nv = SKIP_ONE;
/* pair_counts takes ownership of pk; pair_timestamps
* needs its own copy. */
char *pk2 = malloc(pk_len);
memcpy(pk2, pk, pk_len);
cbm_ht_set(pair_counts, pk, nv);
long long *nts = malloc(sizeof(long long));
*nts = commits[c].timestamp;
cbm_ht_set(pair_timestamps, pk2, nts);
}
}
}
}
collect_coupling_ctx_t cctx = {
.file_counts = file_counts,
.pair_timestamps = pair_timestamps,
.out = out,
.out_count = 0,
.max_out = max_out,
};
cbm_ht_foreach(pair_counts, collect_coupling_cb, &cctx);
cbm_ht_foreach(pair_counts, free_counter, NULL);
cbm_ht_free(pair_counts);
cbm_ht_foreach(pair_timestamps, free_counter, NULL);
cbm_ht_free(pair_timestamps);
cbm_ht_foreach(file_counts, free_counter, NULL);
cbm_ht_free(file_counts);
return cctx.out_count;
}
/* ── Split pass: compute (I/O-bound) + apply (gbuf writes) ───────── */
/* Pre-computed coupling result buffer for fused post-pass parallelism. */
#define MAX_COUPLINGS 8192
#define MAX_FILE_TEMPORAL 16384
/* Compute change couplings without touching the graph buffer.
* Can run on a separate thread while other passes use the gbuf. */
int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result_t *result) {
result->couplings = NULL;
result->count = 0;
result->commit_count = 0;
result->file_temporal = NULL;
result->file_temporal_count = 0;
commit_t *commits = NULL;
int commit_count = 0;
int rc = parse_git_log(repo_path, &commits, &commit_count);
if (rc != 0 || commit_count == 0) {
free(commits);
return 0;
}
result->commit_count = commit_count;
/* Convert to testable format */
cbm_commit_files_t *cf = calloc((size_t)commit_count, sizeof(cbm_commit_files_t));
if (!cf) {
for (int c = 0; c < commit_count; c++) {
commit_free(&commits[c]);
}
free(commits);
return 0;
}
for (int c = 0; c < commit_count; c++) {
cf[c].files = commits[c].files;
cf[c].count = commits[c].count;
cf[c].timestamp = commits[c].timestamp;
}
cbm_change_coupling_t *couplings = malloc(MAX_COUPLINGS * sizeof(cbm_change_coupling_t));
int coupling_count = cbm_compute_change_coupling(cf, commit_count, couplings, MAX_COUPLINGS);
/* Per-file temporal aggregation: change_count + last_modified.
* Single hash-table pass over the same commit set used for coupling so
* we don't re-scan history. NULL on OOM is fine — the caller still
* gets the couplings. */
cbm_file_temporal_t *ft_arr = malloc(MAX_FILE_TEMPORAL * sizeof(cbm_file_temporal_t));
if (ft_arr) {
int ft_count = 0;
CBMHashTable *file_idx = cbm_ht_create(CBM_SZ_1K);
for (int c = 0; c < commit_count; c++) {
if (cf[c].count > GH_MAX_FILES) {
continue;
}
for (int f = 0; f < cf[c].count; f++) {
const char *fp = cf[c].files[f];
int *idx = cbm_ht_get(file_idx, fp);
if (idx) {
ft_arr[*idx].change_count++;
if (cf[c].timestamp > ft_arr[*idx].last_modified) {
ft_arr[*idx].last_modified = cf[c].timestamp;
}
} else if (ft_count < MAX_FILE_TEMPORAL) {
int new_idx = ft_count++;
snprintf(ft_arr[new_idx].file_path, sizeof(ft_arr[new_idx].file_path), "%s",
fp);
ft_arr[new_idx].change_count = 1;
ft_arr[new_idx].last_modified = cf[c].timestamp;
int *nidx = malloc(sizeof(int));
*nidx = new_idx;
cbm_ht_set(file_idx, strdup(fp), nidx);
}
}
}
cbm_ht_foreach(file_idx, free_counter, NULL);
cbm_ht_free(file_idx);
result->file_temporal = ft_arr;
result->file_temporal_count = ft_count;
}
free(cf);
for (int c = 0; c < commit_count; c++) {
commit_free(&commits[c]);
}
free(commits);
result->couplings = couplings;
result->count = coupling_count;
return 0;
}
/* Apply pre-computed couplings to the graph buffer (must be on main thread). */
int cbm_pipeline_githistory_apply(cbm_pipeline_ctx_t *ctx, const cbm_githistory_result_t *result) {
int edge_count = 0;
for (int i = 0; i < result->count; i++) {
const cbm_change_coupling_t *cc = &result->couplings[i];
char *qn_a = cbm_pipeline_fqn_compute(ctx->project_name, cc->file_a, "__file__");
char *qn_b = cbm_pipeline_fqn_compute(ctx->project_name, cc->file_b, "__file__");
const cbm_gbuf_node_t *node_a = cbm_gbuf_find_by_qn(ctx->gbuf, qn_a);
const cbm_gbuf_node_t *node_b = cbm_gbuf_find_by_qn(ctx->gbuf, qn_b);
free(qn_a);
free(qn_b);
if (!node_a || !node_b || node_a->id == node_b->id) {
continue;
}
char props[CBM_SZ_128];
snprintf(props, sizeof(props),
"{\"co_changes\":%d,\"coupling_score\":%.2f,\"last_co_change\":%lld}",
cc->co_change_count, cc->coupling_score, cc->last_co_change);
cbm_gbuf_insert_edge(ctx->gbuf, node_a->id, node_b->id, "FILE_CHANGES_WITH", props);
edge_count++;
}
/* Apply per-file temporal metadata to existing File nodes so callers
* can query change_count / last_modified for hotspot analysis. The
* extension is re-derived and JSON-escaped to keep the props blob
* well-formed even for paths with quotes or backslashes. */
for (int i = 0; i < result->file_temporal_count; i++) {
const cbm_file_temporal_t *ft = &result->file_temporal[i];
char *qn = cbm_pipeline_fqn_compute(ctx->project_name, ft->file_path, "__file__");
const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(ctx->gbuf, qn);
free(qn);
if (!node) {
continue;
}
const char *base = strrchr(ft->file_path, '/');
base = base ? base + SKIP_ONE : ft->file_path;
const char *ext = strrchr(base, '.');
char ext_escaped[CBM_SZ_64];
cbm_json_escape(ext_escaped, (int)sizeof(ext_escaped), ext ? ext : "");
char props[CBM_SZ_256];
snprintf(props, sizeof(props),
"{\"extension\":\"%s\",\"last_modified\":%lld,\"change_count\":%d}", ext_escaped,
ft->last_modified, ft->change_count);
cbm_gbuf_upsert_node(ctx->gbuf, node->label, node->name, node->qualified_name,
node->file_path, node->start_line, node->end_line, props);
}
return edge_count;
}
/* ── Main pass (original serial interface) ───────────────────────── */
int cbm_pipeline_pass_githistory(cbm_pipeline_ctx_t *ctx) {
cbm_log_info("pass.start", "pass", "githistory");
cbm_githistory_result_t result = {0};
cbm_pipeline_githistory_compute(ctx->repo_path, &result);
int edge_count = 0;
if (result.count > 0 || result.file_temporal_count > 0) {
edge_count = cbm_pipeline_githistory_apply(ctx, &result);
}
free(result.couplings);
free(result.file_temporal);
cbm_log_info("pass.done", "pass", "githistory", "commits", itoa_log(result.commit_count),
"edges", itoa_log(edge_count));
return 0;
}
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
/*
* pass_k8s.c — Pipeline pass for Kubernetes manifest and Kustomize overlay processing.
*
* For each discovered YAML file:
* 1. Check if it is a kustomize overlay (kustomization.yaml / kustomization.yml)
* → emit a Module node and IMPORTS edges for each resources/bases/patches entry
* 2. Else if it is a generic k8s manifest (apiVersion: detected)
* → emit one Resource node per file (first document only — multi-document YAML is not yet
* supported)
*
* Depends on: pass_infrascan.c (cbm_is_kustomize_file, cbm_is_k8s_manifest, cbm_infra_qn),
* extraction layer (cbm.h), graph_buffer, pipeline internals.
*/
#include "foundation/constants.h"
#include "pipeline/pipeline.h"
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "discover/discover.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "cbm.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
/* ── Internal helpers ────────────────────────────────────────────── */
/* Read entire file into heap-allocated buffer. Returns NULL on error.
* Caller must free(). Sets *out_len to byte count. */
static char *k8s_read_file(const char *path, int *out_len) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */
(void)fclose(f);
return NULL;
}
/* +pad: tree-sitter lexer lookahead reads past EOF; keep it in-bounds */
enum { CBM_TS_LOOKAHEAD_PAD = 16 };
char *buf = malloc((size_t)size + CBM_TS_LOOKAHEAD_PAD);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t nread = fread(buf, SKIP_ONE, size, f);
(void)fclose(f);
if (nread > (size_t)size) {
nread = (size_t)size;
}
memset(buf + nread, 0, CBM_TS_LOOKAHEAD_PAD);
*out_len = (int)nread;
return buf;
}
/* Format int to string for logging. Thread-safe via TLS. */
static const char *itoa_k8s(int val) {
enum { RING_BUF_COUNT = 4, RING_BUF_MASK = 3 };
static CBM_TLS char bufs[RING_BUF_COUNT][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & RING_BUF_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Extract the basename of a path (pointer into the string; no allocation). */
static const char *k8s_basename(const char *path) {
const char *p = strrchr(path, '/');
return p ? p + SKIP_ONE : path;
}
/* ── Kustomize handler ───────────────────────────────────────────── */
static void handle_kustomize(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path,
CBMFileResult *result) {
/* Emit Module node for this kustomize overlay file */
char *mod_qn = cbm_infra_qn(ctx->project_name, rel_path, "kustomize", NULL);
if (!mod_qn) {
return;
}
int64_t mod_id = cbm_gbuf_upsert_node(ctx->gbuf, "Module", k8s_basename(rel_path), mod_qn,
rel_path, SKIP_ONE, 0, "{\"source\":\"kustomize\"}");
free(mod_qn);
if (mod_id <= 0) {
return;
}
/* If we have a cached extraction result, emit IMPORTS edges for
* resources/bases/patches/components entries */
int import_count = 0;
CBMFileResult *res = result;
bool allocated = false;
if (!res) {
/* Fall back to re-extraction */
int src_len = 0;
char *source = k8s_read_file(path, &src_len);
if (source) {
res = cbm_extract_file(source, src_len, CBM_LANG_KUSTOMIZE, ctx->project_name, rel_path,
CBM_EXTRACT_BUDGET, NULL, NULL);
free(source);
allocated = true;
}
}
if (res) {
for (int j = 0; j < res->imports.count; j++) {
CBMImport *imp = &res->imports.items[j];
if (!imp->module_path) {
continue;
}
/* Compute target file QN */
char *target_qn =
cbm_pipeline_fqn_compute(ctx->project_name, imp->module_path, "__file__");
if (!target_qn) {
continue;
}
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
free(target_qn);
if (target) {
cbm_gbuf_insert_edge(ctx->gbuf, mod_id, target->id, "IMPORTS",
"{\"via\":\"kustomize\"}");
import_count++;
}
}
if (allocated) {
cbm_free_result(res);
}
}
cbm_log_info("pass.k8s.kustomize", "file", rel_path, "imports", itoa_k8s(import_count));
}
/* ── K8s cross-manifest label-selector matching ──────────────────────
* A Service routes traffic to the workload Pods whose labels match the
* Service's spec.selector. We record, per manifest, the Resource node id plus
* its selector label-values (Services) and pod label-values (workloads), then
* after all manifests are processed connect each Service to the workload(s) it
* targets via an INFRA_MAPS edge. This models the runtime traffic path the same
* way k8s itself resolves Service → Endpoints by label.
*
* Matching is by label *value*: a Service selector `app: frontend` matches a
* workload that either carries the pod label value "frontend" OR is named
* "frontend" (the ubiquitous app==name convention, used when the pod template
* omits explicit labels). */
enum { K8S_MAX_LABELS = 16, K8S_MAX_RECORDS = 512, K8S_LABEL_LEN = 128 };
typedef struct {
int64_t node_id;
char name[K8S_LABEL_LEN];
bool is_service;
bool is_workload;
char selector_vals[K8S_MAX_LABELS][K8S_LABEL_LEN]; /* Service spec.selector values */
int n_selector;
char label_vals[K8S_MAX_LABELS][K8S_LABEL_LEN]; /* workload pod-template label values */
int n_label;
} k8s_record_t;
typedef struct {
k8s_record_t *items;
int count;
int cap;
} k8s_record_array_t;
/* Count leading-space indentation of a line (tabs are invalid YAML indent). */
static int k8s_indent(const char *line) {
int n = 0;
while (line[n] == ' ') {
n++;
}
return n;
}
/* Split `key: value` (already de-indented). Returns 1 if a key was found; fills
* key/val (val empty when the key opens a nested block). */
static int k8s_split_kv(const char *t, char *key, size_t key_sz, char *val, size_t val_sz) {
key[0] = '\0';
val[0] = '\0';
if (t[0] == '#' || t[0] == '-' || t[0] == '\0') {
return 0;
}
const char *colon = strchr(t, ':');
if (!colon) {
return 0;
}
size_t klen = (size_t)(colon - t);
if (klen == 0 || klen >= key_sz) {
return 0;
}
memcpy(key, t, klen);
key[klen] = '\0';
const char *v = colon + 1;
while (*v == ' ' || *v == '\t') {
v++;
}
size_t vn = 0;
while (v[vn] && v[vn] != '\r' && v[vn] != '\n' && v[vn] != '#' && vn + 1 < val_sz) {
val[vn] = v[vn];
vn++;
}
/* trim trailing space */
while (vn > 0 && (val[vn - 1] == ' ' || val[vn - 1] == '\t')) {
vn--;
}
val[vn] = '\0';
/* strip surrounding quotes */
if (vn >= 2 && (val[0] == '"' || val[0] == '\'') && val[vn - 1] == val[0]) {
memmove(val, val + 1, vn - 2);
val[vn - 2] = '\0';
}
return 1;
}
static void k8s_add_val(char dst[][K8S_LABEL_LEN], int *n, const char *v) {
if (*n >= K8S_MAX_LABELS || !v || !v[0]) {
return;
}
snprintf(dst[*n], K8S_LABEL_LEN, "%s", v);
(*n)++;
}
/* Scan a single-document k8s manifest's text for the resource name, selector
* label-values (Service) and pod-template label-values (workload). A small
* indentation path-stack distinguishes spec.selector from
* spec.template.metadata.labels and from top-level metadata.name. */
static void k8s_scan_labels(const char *source, k8s_record_t *rec) {
enum { K8S_PATH_DEPTH = 12 };
struct {
int indent;
char key[64];
} stack[K8S_PATH_DEPTH];
int depth = 0;
bool got_name = false;
const char *p = source;
while (p && *p) {
const char *eol = strchr(p, '\n');
size_t len = eol ? (size_t)(eol - p) : strlen(p);
char line[CBM_SZ_512];
size_t cp = len < sizeof(line) - 1 ? len : sizeof(line) - 1;
memcpy(line, p, cp);
line[cp] = '\0';
/* End of first YAML document — stop (one Resource per file). */
const char *trimmed = line;
while (*trimmed == ' ') {
trimmed++;
}
if (strncmp(trimmed, "---", 3) == 0 && line == trimmed) {
break;
}
if (trimmed[0] && trimmed[0] != '#') {
int ind = k8s_indent(line);
while (depth > 0 && stack[depth - 1].indent >= ind) {
depth--;
}
char key[64];
char val[K8S_LABEL_LEN];
if (k8s_split_kv(trimmed, key, sizeof(key), val, sizeof(val))) {
/* Build the current dotted path for context decisions. */
bool under_selector = (depth >= 1 && strcmp(stack[depth - 1].key, "selector") == 0);
bool under_labels = (depth >= 1 && strcmp(stack[depth - 1].key, "labels") == 0);
bool under_metadata = (depth >= 1 && strcmp(stack[depth - 1].key, "metadata") == 0);
if (val[0] == '\0') {
/* Block-opening key: push onto the path stack. */
if (depth < K8S_PATH_DEPTH) {
stack[depth].indent = ind;
snprintf(stack[depth].key, sizeof(stack[depth].key), "%s", key);
depth++;
}
} else {
/* Leaf key: value. */
if (ind == 0 && strcmp(key, "kind") == 0) {
rec->is_service = (strcmp(val, "Service") == 0);
rec->is_workload =
(strcmp(val, "Deployment") == 0 || strcmp(val, "StatefulSet") == 0 ||
strcmp(val, "DaemonSet") == 0 || strcmp(val, "ReplicaSet") == 0 ||
strcmp(val, "ReplicationController") == 0 || strcmp(val, "Pod") == 0 ||
strcmp(val, "Job") == 0 || strcmp(val, "CronJob") == 0);
} else if (!got_name && under_metadata && strcmp(key, "name") == 0) {
snprintf(rec->name, sizeof(rec->name), "%s", val);
got_name = true;
} else if (under_selector) {
/* Service spec.selector matchLabels values. */
k8s_add_val(rec->selector_vals, &rec->n_selector, val);
} else if (under_labels) {
/* Pod-template / metadata labels values. */
k8s_add_val(rec->label_vals, &rec->n_label, val);
}
}
}
}
if (!eol) {
break;
}
p = eol + 1;
}
}
/* True if any of the service's selector values matches the workload. */
static bool k8s_selector_matches(const k8s_record_t *svc, const k8s_record_t *wl) {
for (int s = 0; s < svc->n_selector; s++) {
const char *sv = svc->selector_vals[s];
if (!sv[0]) {
continue;
}
if (wl->name[0] && strcmp(sv, wl->name) == 0) {
return true;
}
for (int l = 0; l < wl->n_label; l++) {
if (strcmp(sv, wl->label_vals[l]) == 0) {
return true;
}
}
}
return false;
}
/* After all manifests are recorded, connect each Service to the workload(s) its
* selector targets via an INFRA_MAPS edge (Service Resource → workload Resource). */
static void k8s_link_selectors(cbm_pipeline_ctx_t *ctx, const k8s_record_array_t *recs) {
int edges = 0;
for (int i = 0; i < recs->count; i++) {
const k8s_record_t *svc = &recs->items[i];
if (!svc->is_service || svc->n_selector == 0 || svc->node_id <= 0) {
continue;
}
for (int j = 0; j < recs->count; j++) {
const k8s_record_t *wl = &recs->items[j];
if (i == j || !wl->is_workload || wl->node_id <= 0) {
continue;
}
if (k8s_selector_matches(svc, wl)) {
char props[CBM_SZ_256];
snprintf(props, sizeof(props),
"{\"kind\":\"selector\",\"service\":\"%s\",\"workload\":\"%s\"}",
svc->name, wl->name);
cbm_gbuf_insert_edge(ctx->gbuf, svc->node_id, wl->node_id, "INFRA_MAPS", props);
edges++;
}
}
}
if (edges > 0) {
cbm_log_info("pass.k8s.selectors", "linked", itoa_k8s(edges));
}
}
/* ── K8s manifest handler ────────────────────────────────────────── */
/* source/src_len are the already-read file bytes (caller retains ownership and
* must free after this call returns). When `rec` is non-NULL it is populated
* with the first Resource's node id, name and label/selector values for later
* cross-manifest selector matching. */
static void handle_k8s_manifest(cbm_pipeline_ctx_t *ctx, const char *path, const char *rel_path,
const char *source, int src_len, k8s_record_t *rec) {
(void)path; /* retained for symmetry; source is always provided now */
int resource_count = 0;
CBMFileResult *res = cbm_extract_file(source, src_len, CBM_LANG_K8S, ctx->project_name,
rel_path, CBM_EXTRACT_BUDGET, NULL, NULL);
if (!res) {
return;
}
/* Compute file node QN for DEFINES edges */
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL;
free(file_qn);
for (int d = 0; d < res->defs.count; d++) {
CBMDefinition *def = &res->defs.items[d];
if (!def->label || strcmp(def->label, "Resource") != 0) {
continue;
}
if (!def->name || !def->qualified_name) {
continue;
}
int64_t node_id =
cbm_gbuf_upsert_node(ctx->gbuf, "Resource", def->name, def->qualified_name, rel_path,
(int)def->start_line, (int)def->end_line, "{\"source\":\"k8s\"}");
/* DEFINES edge: File → Resource */
if (file_node && node_id > 0) {
cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, node_id, "DEFINES", "{}");
}
/* Capture the first Resource for cross-manifest selector matching. */
if (rec && rec->node_id <= 0 && node_id > 0) {
rec->node_id = node_id;
}
resource_count++;
}
cbm_free_result(res);
/* Record selector / pod-label values for later Service → workload linking. */
if (rec && rec->node_id > 0) {
k8s_scan_labels(source, rec);
}
cbm_log_info("pass.k8s.manifest", "file", rel_path, "resources", itoa_k8s(resource_count));
}
/* ── Helm chart handler ──────────────────────────────────────────── */
static bool is_helm_chart_file(const char *base) {
return strcmp(base, "Chart.yaml") == 0 || strcmp(base, "Chart.yml") == 0;
}
/* Emit a Chart node for a Chart.yaml and a DEPENDS_ON edge to a (shared,
* deduplicated) Chart node per declared dependency (#338). */
static void handle_helm_chart(cbm_pipeline_ctx_t *ctx, const char *rel_path, const char *source) {
cbm_helm_chart_t hc;
if (cbm_parse_helm_chart(source, &hc) != 0) {
return;
}
const char *cname = hc.chart_name[0] ? hc.chart_name : k8s_basename(rel_path);
char *chart_qn = cbm_infra_qn(ctx->project_name, rel_path, "helm-chart", NULL);
if (!chart_qn) {
return;
}
int64_t chart_id = cbm_gbuf_upsert_node(ctx->gbuf, "Chart", cname, chart_qn, rel_path, SKIP_ONE,
0, "{\"source\":\"helm\"}");
free(chart_qn);
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *file_node = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL;
if (file_node && chart_id > 0) {
cbm_gbuf_insert_edge(ctx->gbuf, file_node->id, chart_id, "DEFINES", "{}");
}
free(file_qn);
int dep_edges = 0;
for (int i = 0; i < hc.dep_count && chart_id > 0; i++) {
/* Stable per-project QN so multiple charts depending on the same chart
* link to one shared dependency node. */
char dep_qn[CBM_SZ_512];
snprintf(dep_qn, sizeof(dep_qn), "%s.__helm_dep__.%s", ctx->project_name, hc.deps[i]);
int64_t dep_id =
cbm_gbuf_upsert_node(ctx->gbuf, "Chart", hc.deps[i], dep_qn, rel_path, SKIP_ONE, 0,
"{\"source\":\"helm\",\"external\":true}");
if (dep_id > 0) {
cbm_gbuf_insert_edge(ctx->gbuf, chart_id, dep_id, "DEPENDS_ON", "{}");
dep_edges++;
}
}
cbm_log_info("pass.k8s.helm", "file", rel_path, "deps", itoa_k8s(dep_edges));
}
/* ── Dependency-manifest handler (go.mod / requirements.txt) ──────── */
static bool is_gomod_file(const char *base) {
return strcmp(base, "go.mod") == 0;
}
static bool is_requirements_file(const char *base) {
return strcmp(base, "requirements.txt") == 0;
}
/* Emit a DEPENDS_ON edge from the manifest file node to a (shared, per-project)
* external Package node. Mirrors the Helm Chart.yaml DEPENDS_ON shape. */
static int emit_dep_edge(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *src, const char *rel_path,
const char *ecosystem, const char *name) {
if (!name || !name[0]) {
return 0;
}
char dep_qn[CBM_SZ_512];
snprintf(dep_qn, sizeof(dep_qn), "%s.__%s_dep__.%s", ctx->project_name, ecosystem, name);
char dep_props[CBM_SZ_256];
snprintf(dep_props, sizeof(dep_props), "{\"source\":\"%s\",\"external\":true}", ecosystem);
int64_t dep_id =
cbm_gbuf_upsert_node(ctx->gbuf, "Package", name, dep_qn, rel_path, SKIP_ONE, 0, dep_props);
if (dep_id > 0 && dep_id != src->id) {
cbm_gbuf_insert_edge(ctx->gbuf, src->id, dep_id, "DEPENDS_ON", "{}");
return 1;
}
return 0;
}
/* Copy the first whitespace-delimited token of `line` into `out`. */
static void first_token(const char *line, char *out, size_t out_sz) {
out[0] = '\0';
while (*line == ' ' || *line == '\t') {
line++;
}
size_t n = 0;
while (line[n] && line[n] != ' ' && line[n] != '\t' && line[n] != '\r' && line[n] != '\n' &&
n + 1 < out_sz) {
out[n] = line[n];
n++;
}
out[n] = '\0';
}
/* Parse go.mod `require` directives (single-line and block forms) and emit a
* DEPENDS_ON edge per dependency. go.mod requires are not surfaced as imports
* by the extraction layer, so we parse the manifest text directly here. */
static int parse_gomod_deps(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *src,
const char *rel_path, const char *source) {
int edges = 0;
bool in_block = false;
const char *p = source;
while (p && *p) {
const char *eol = strchr(p, '\n');
size_t len = eol ? (size_t)(eol - p) : strlen(p);
char line[CBM_SZ_512];
size_t cp = len < sizeof(line) - 1 ? len : sizeof(line) - 1;
memcpy(line, p, cp);
line[cp] = '\0';
const char *t = line;
while (*t == ' ' || *t == '\t') {
t++;
}
if (in_block) {
if (t[0] == ')') {
in_block = false;
} else if (t[0] && t[0] != '/') {
char name[CBM_SZ_256];
first_token(t, name, sizeof(name));
edges += emit_dep_edge(ctx, src, rel_path, "gomod", name);
}
} else if (strncmp(t, "require", 7) == 0 && (t[7] == ' ' || t[7] == '\t' || t[7] == '(')) {
const char *rest = t + 7;
while (*rest == ' ' || *rest == '\t') {
rest++;
}
if (*rest == '(') {
in_block = true;
} else if (*rest) {
char name[CBM_SZ_256];
first_token(rest, name, sizeof(name));
edges += emit_dep_edge(ctx, src, rel_path, "gomod", name);
}
}
if (!eol) {
break;
}
p = eol + 1;
}
return edges;
}
/* Parse requirements.txt entries (one package spec per line) and emit a
* DEPENDS_ON edge per dependency. The package name is the leading token up to
* the first version/extras/comment delimiter. */
static int parse_requirements_deps(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *src,
const char *rel_path, const char *source) {
int edges = 0;
const char *p = source;
while (p && *p) {
const char *eol = strchr(p, '\n');
size_t len = eol ? (size_t)(eol - p) : strlen(p);
char line[CBM_SZ_512];
size_t cp = len < sizeof(line) - 1 ? len : sizeof(line) - 1;
memcpy(line, p, cp);
line[cp] = '\0';
const char *t = line;
while (*t == ' ' || *t == '\t') {
t++;
}
/* Skip blanks, comments, options (-r, --hash), and URLs. */
if (t[0] && t[0] != '#' && t[0] != '-' && strstr(t, "://") == NULL) {
char name[CBM_SZ_256];
size_t n = 0;
while (t[n] && t[n] != '=' && t[n] != '<' && t[n] != '>' && t[n] != '!' &&
t[n] != '~' && t[n] != '[' && t[n] != ';' && t[n] != ' ' && t[n] != '\t' &&
t[n] != '\r' && n + 1 < sizeof(name)) {
name[n] = t[n];
n++;
}
name[n] = '\0';
edges += emit_dep_edge(ctx, src, rel_path, "pypi", name);
}
if (!eol) {
break;
}
p = eol + 1;
}
return edges;
}
static void handle_dep_manifest(cbm_pipeline_ctx_t *ctx, const char *rel_path, const char *source,
const char *ecosystem) {
if (!source) {
return;
}
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *src = file_qn ? cbm_gbuf_find_by_qn(ctx->gbuf, file_qn) : NULL;
free(file_qn);
if (!src) {
return;
}
int dep_edges = strcmp(ecosystem, "gomod") == 0
? parse_gomod_deps(ctx, src, rel_path, source)
: parse_requirements_deps(ctx, src, rel_path, source);
cbm_log_info("pass.k8s.depmanifest", "file", rel_path, "deps", itoa_k8s(dep_edges));
}
/* ── Pass entry point ────────────────────────────────────────────── */
int cbm_pipeline_pass_k8s(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count) {
cbm_log_info("pass.start", "pass", "k8s", "files", itoa_k8s(file_count));
cbm_init();
int kustomize_count = 0;
int manifest_count = 0;
int helm_count = 0;
/* Collect per-manifest selector/label records for cross-manifest matching. */
k8s_record_array_t recs = {0};
recs.items = calloc(K8S_MAX_RECORDS, sizeof(*recs.items));
recs.cap = recs.items ? K8S_MAX_RECORDS : 0;
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
free(recs.items);
return CBM_NOT_FOUND;
}
const char *path = files[i].path;
const char *rel = files[i].rel_path;
CBMLanguage lang = files[i].language;
const char *base = k8s_basename(rel);
CBMFileResult *cached =
(ctx->result_cache && ctx->result_cache[i]) ? ctx->result_cache[i] : NULL;
if (is_gomod_file(base) || lang == CBM_LANG_GOMOD || is_requirements_file(base)) {
int dep_len = 0;
char *dep_src = k8s_read_file(path, &dep_len);
if (dep_src) {
handle_dep_manifest(ctx, rel, dep_src,
is_requirements_file(base) ? "pypi" : "gomod");
free(dep_src);
}
} else if (cbm_is_kustomize_file(base)) {
handle_kustomize(ctx, path, rel, cached);
kustomize_count++;
} else if (lang == CBM_LANG_YAML || lang == CBM_LANG_K8S) {
/* Read source once to classify (and reuse for uncached extraction). */
int src_len = 0;
char *source = k8s_read_file(path, &src_len);
if (source) {
if (is_helm_chart_file(base)) {
handle_helm_chart(ctx, rel, source);
helm_count++;
} else if (cbm_is_k8s_manifest(base, source)) {
/* Always re-extract with CBM_LANG_K8S regardless of any cached
* result: cached results were produced during the parallel YAML
* pass and contain no "Resource" definitions. Pass the already-
* read source buffer so handle_k8s_manifest does not re-read. */
(void)cached; /* cached YAML result intentionally discarded */
k8s_record_t *rec = (recs.count < recs.cap) ? &recs.items[recs.count] : NULL;
handle_k8s_manifest(ctx, path, rel, source, src_len, rec);
if (rec && rec->node_id > 0) {
recs.count++;
}
manifest_count++;
}
free(source);
}
}
}
/* Connect Services to the workloads their selectors target (INFRA_MAPS). */
k8s_link_selectors(ctx, &recs);
free(recs.items);
cbm_log_info("pass.done", "pass", "k8s", "kustomize", itoa_k8s(kustomize_count), "manifests",
itoa_k8s(manifest_count));
(void)helm_count;
return 0;
}
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
/*
* pass_lsp_cross.h — Cross-file LSP helpers shared with the parallel
* resolve pass.
*
* Per-file LSP (cbm_run_X_lsp inside cbm_extract_file) only sees a single
* file's defs in its registry, so callees whose receiver type comes from
* an imported module stay unresolved. The helpers declared here close
* that gap: they let the parallel resolve worker (pass_parallel.c) build
* a project-wide CBMLSPDef[] and invoke the language-specific
* cbm_run_X_lsp_cross resolver on each file using the file's already-
* built import map. Resolved calls are appended to result->resolved_calls
* so the same cbm_pipeline_find_lsp_resolution path that handles per-
* file LSP picks them up.
*
* Languages covered: Go, C/C++/CUDA, Python, TypeScript/JavaScript/JSX/
* TSX, PHP, C#, and JVM (Java/Kotlin via the shared filter helper).
* Anything else short-circuits via cbm_pxc_has_cross_lsp.
*
* Previously this work ran as a separate sequential pipeline pass
* (cbm_pipeline_pass_lsp_cross) that re-read every source file from
* disk and re-parsed each tree-sitter tree on a single thread — a 50×
* regression vs the parallel extract pass on large repos. The pass was
* deleted; the resolve worker now invokes these helpers directly using
* the source bytes retained in result->arena during extract.
*/
#ifndef CBM_PIPELINE_PASS_LSP_CROSS_H
#define CBM_PIPELINE_PASS_LSP_CROSS_H
#include "cbm.h"
/* CBMLSPDef historically lives in lsp/go_lsp.h (not lsp/type_rep.h)
* — type_rep.h covers the type-representation primitives while
* go_lsp.h was where the project-wide def descriptor landed first. */
#include "lsp/go_lsp.h"
#include "lsp/py_lsp.h" /* cbm_py_build_cross_registry / cbm_run_py_lsp_cross_with_registry */
#include "lsp/c_lsp.h" /* cbm_c_build_cross_registry / cbm_run_c_lsp_cross_with_registry */
#include "lsp/cs_lsp.h" /* cbm_cs_build_cross_registry / cbm_run_cs_lsp_cross_with_registry */
#include "lsp/ts_lsp.h" /* cbm_ts_build_cross_registry / cbm_run_ts_lsp_cross_with_registry */
#include "lsp/rust_lsp.h" /* cbm_rust_build_cross_registry / cbm_run_rust_lsp_cross_with_registry */
#include "pipeline/pipeline_internal.h"
#include <stdbool.h>
/* True iff this language has a cbm_run_X_lsp_cross resolver wired up. */
bool cbm_pxc_has_cross_lsp(CBMLanguage lang);
/* Collect a project-wide CBMLSPDef[] from every cached file result.
* def_modules[i] receives the module QN for files[i] (malloc'd; the
* caller frees each entry then the array). String fields in the
* returned CBMLSPDef[] are borrowed from cache[i]->arena and from
* def_modules[i] — caller must keep both alive while the array is in
* use. Returns the malloc'd array (free() it) and writes the entry
* count to *out_count. Returns NULL on alloc failure or when no defs
* exist. */
CBMLSPDef *cbm_pxc_collect_all_defs(CBMFileResult **cache, const cbm_file_info_t *files,
int file_count, const char *project_name, char **def_modules,
int *out_count);
/* Detect TS dialect flags from a relative path. */
void cbm_pxc_ts_modes(CBMLanguage lang, const char *rel_path, bool *out_js, bool *out_jsx,
bool *out_dts);
/* ── Per-module def index (the gopls "package summary" pattern) ──
*
* The hot path used to register ALL all_defs[] into a fresh registry
* per file (~110k defs × 11k files for kubernetes = ~21,000 CPU-s of
* arena_strdup). Most of those defs are irrelevant to any one file —
* each file only references defs from its own module + its imported
* modules. gopls observed the same: it builds per-package summaries
* and per-file only loads the summaries the file imports.
*
* cbm_pxc_build_module_def_index() builds inverted indexes once (O(D)):
* def_module_qn → defs and declared namespace/package → defs.
* cbm_pxc_filter_defs_for_file() then returns own_module + imp_qns for
* most languages. For Java/Kotlin callers it additionally returns
* same-namespace JVM defs so Gradle/Maven mixed source roots
* (`src/main/java/...` + `src/main/kotlin/...`) resolve same-package
* references without falling back to a full project registry per file. */
typedef struct CBMModuleDefIndex CBMModuleDefIndex;
CBMModuleDefIndex *cbm_pxc_build_module_def_index(CBMLSPDef *all_defs, int def_count);
void cbm_pxc_free_module_def_index(CBMModuleDefIndex *idx);
/* Return a malloc'd CBMLSPDef[] containing all defs whose
* def_module_qn matches own_module OR any of imp_qns. For Java/Kotlin
* callers, also include defs from the same declared package/namespace:
* JVM same-package references often cross `src/main/java` and
* `src/main/kotlin` roots without import statements. String fields inside
* each entry are borrowed from the original all_defs[] arena (caller keeps
* it alive). Caller frees the returned array with free(). Writes the entry
* count to *out_count. Returns NULL if no matches (with *out_count = 0). */
CBMLSPDef *cbm_pxc_filter_defs_for_file(const CBMModuleDefIndex *idx, CBMLSPDef *all_defs,
CBMLanguage caller_lang, const char *caller_namespace,
const char *own_module, const char *const *imp_qns,
int imp_count, int *out_count);
/* ── Tier 2 full: pre-built per-language cross-LSP registries ─────
*
* Each non-NULL registry is built ONCE in pipeline.c (in a dedicated
* cross_lsp_arena), finalized, and shared READ-ONLY across all
* resolve workers for files of that language. The worker uses the
* matching cbm_run_X_lsp_cross_with_registry variant which skips the
* per-file registry build entirely. NULL → fall back to the per-file
* cbm_pxc_run_one path. */
typedef struct {
CBMTypeRegistry *go; /* CBM_LANG_GO */
CBMTypeRegistry *c; /* CBM_LANG_C, CBM_LANG_CPP, CBM_LANG_CUDA */
CBMTypeRegistry *python; /* CBM_LANG_PYTHON */
CBMTypeRegistry *ts; /* CBM_LANG_JAVASCRIPT, TYPESCRIPT, TSX */
CBMTypeRegistry *php; /* CBM_LANG_PHP */
CBMTypeRegistry *cs; /* CBM_LANG_CSHARP */
/* CBM_LANG_RUST: intentionally absent — the shared rust registry is built
* LAZILY inside cbm_parallel_resolve (first NULL-filter rust file), not eagerly. */
} CBMCrossLspRegistries;
/* Return the appropriate pre-built registry for a language, or NULL
* if none was built (or language has no cross-LSP entrypoint). */
static inline CBMTypeRegistry *cbm_pxc_registry_for_lang(const CBMCrossLspRegistries *r,
CBMLanguage lang) {
if (!r)
return NULL;
switch (lang) {
case CBM_LANG_GO:
return r->go;
case CBM_LANG_C: /* fallthrough */
case CBM_LANG_CPP: /* fallthrough */
case CBM_LANG_CUDA:
return r->c;
case CBM_LANG_PYTHON:
return r->python;
case CBM_LANG_JAVASCRIPT: /* fallthrough */
case CBM_LANG_TYPESCRIPT: /* fallthrough */
case CBM_LANG_TSX:
return r->ts;
case CBM_LANG_PHP:
return r->php;
case CBM_LANG_CSHARP:
return r->cs;
default:
return NULL; /* incl. CBM_LANG_RUST — its shared registry is built lazily */
}
}
/* Borrow the (thread-local) Rust Cargo manifest the cross-file LSP pass set for
* cross-crate (#56) routing. The Tier-2 prebuilt Rust resolve reads it so it sees
* exactly what the per-file fallback (cbm_pxc_run_one) would on the same thread. */
struct CBMCargoManifest;
const struct CBMCargoManifest *cbm_pxc_get_rust_manifest(void);
/* Run the cross-file LSP resolver for non-TS languages. Appends
* resolved CALLS into r->resolved_calls (lives in r->arena). Caller
* owns source, module_qn, all_defs, imp_keys, imp_vals.
* NOTE: all_defs is read-only in practice but typed non-const to match
* the existing cbm_run_X_lsp_cross callee signatures. */
void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int source_len,
const char *module_qn, CBMLSPDef *all_defs, int def_count,
const char **imp_keys, const char **imp_vals, int imp_count);
/* TS / JS / JSX / TSX variant with explicit dialect flags. */
void cbm_pxc_run_one_ts(CBMFileResult *r, const char *source, int source_len, const char *module_qn,
CBMLSPDef *all_defs, int def_count, const char **imp_keys,
const char **imp_vals, int imp_count, bool js_mode, bool jsx_mode,
bool dts_mode);
/* Per-file cross-LSP dispatch shared by the parallel resolve worker AND the
* sequential driver (one path = one semantics): module-def-index filter →
* shared prebuilt registry (overlay pattern, no per-file registry build) →
* per-file fallback with FILTERED defs for languages without a shared
* variant. rust_shared_get (nullable) supplies the lazily-built shared Rust
* registry for NULL-filter rust files. */
void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char *source,
int source_len, const char *rel, const char *def_module,
const CBMCrossLspRegistries *cross_registries,
const CBMModuleDefIndex *module_def_index, CBMLSPDef *all_defs,
int all_def_count, const char **imp_keys, const char **imp_vals,
int imp_count, CBMTypeRegistry *(*rust_shared_get)(void *),
void *rust_shared_ctx);
#endif /* CBM_PIPELINE_PASS_LSP_CROSS_H */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+583
View File
@@ -0,0 +1,583 @@
/*
* pass_semantic.c — Semantic edge passes: INHERITS, DECORATES, IMPLEMENTS.
*
* Operates on already-extracted nodes in the graph buffer:
* - INHERITS: Class/Interface → base class (from base_classes in extraction)
* - DECORATES: Function/Method → decorator function (from decorators in extraction)
* - IMPLEMENTS: Struct/Class → Interface (Go implicit + explicit base_classes + Rust impl)
*
* These passes re-extract files to access base_classes, decorators, and impl_traits data
* since that information is in CBMDefinition fields, not stored in node properties JSON.
*
* Depends on: pass_definitions having populated the registry and graph buffer
*/
#include "foundation/constants.h"
#include "foundation/str_util.h" // cbm_json_escape
#include "pipeline/pipeline.h"
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "cbm.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* True for languages whose module QN derives from the CONTAINING DIRECTORY
* (Java/Go package). MUST match cbm_lang_module_is_dir() (internal/cbm/helpers.c)
* so base-class / same-module resolution keys against the directory-based
* def-node QNs. */
static bool ps_module_is_dir(CBMLanguage lang) {
return lang == CBM_LANG_JAVA || lang == CBM_LANG_GO;
}
static char *read_file(const char *path, int *out_len) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */
(void)fclose(f);
return NULL;
}
/* +pad: tree-sitter lexer lookahead reads past EOF; keep it in-bounds */
enum { CBM_TS_LOOKAHEAD_PAD = 16 };
char *buf = malloc((size_t)size + CBM_TS_LOOKAHEAD_PAD);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t nread = fread(buf, SKIP_ONE, size, f);
(void)fclose(f);
if (nread > (size_t)size) {
nread = (size_t)size;
}
memset(buf + nread, 0, CBM_TS_LOOKAHEAD_PAD);
*out_len = (int)nread;
return buf;
}
static const char *itoa_log(int val) {
enum { RING_BUF_COUNT = 4, RING_BUF_MASK = 3 };
static CBM_TLS char bufs[RING_BUF_COUNT][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & RING_BUF_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Build per-file import map from cached extraction result or graph buffer edges. */
static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path,
const CBMFileResult *result, const char ***out_keys,
const char ***out_vals, int *out_count) {
*out_keys = NULL;
*out_vals = NULL;
*out_count = 0;
/* Fast path: build from cached extraction result (no JSON parsing) */
if (result && result->imports.count > 0) {
const char **keys = calloc((size_t)result->imports.count, sizeof(const char *));
const char **vals = calloc((size_t)result->imports.count, sizeof(const char *));
int count = 0;
for (int i = 0; i < result->imports.count; i++) {
const CBMImport *imp = &result->imports.items[i];
if (!imp->local_name || !imp->local_name[0] || !imp->module_path) {
continue;
}
char *target_qn = cbm_pipeline_fqn_module(ctx->project_name, imp->module_path);
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
free(target_qn);
if (!target) {
continue;
}
keys[count] = strdup(imp->local_name);
vals[count] = target->qualified_name;
count++;
}
*out_keys = keys;
*out_vals = vals;
*out_count = count;
return 0;
}
/* Slow path: scan graph buffer IMPORTS edges + parse JSON properties */
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
free(file_qn);
if (!file_node) {
return 0;
}
const cbm_gbuf_edge_t **edges = NULL;
int edge_count = 0;
int rc = cbm_gbuf_find_edges_by_source_type(ctx->gbuf, file_node->id, "IMPORTS", &edges,
&edge_count);
if (rc != 0 || edge_count == 0) {
return 0;
}
const char **keys = calloc(edge_count, sizeof(const char *));
const char **vals = calloc(edge_count, sizeof(const char *));
int count = 0;
for (int i = 0; i < edge_count; i++) {
const cbm_gbuf_edge_t *e = edges[i];
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(ctx->gbuf, e->target_id);
if (!target || !e->properties_json) {
continue;
}
const char *start = strstr(e->properties_json, "\"local_name\":\"");
if (start) {
start += strlen("\"local_name\":\"");
const char *end = strchr(start, '"');
if (end && end > start) {
keys[count] = cbm_strndup(start, end - start);
vals[count] = target->qualified_name;
count++;
}
}
}
*out_keys = keys;
*out_vals = vals;
*out_count = count;
return 0;
}
static void free_import_map(const char **keys, const char **vals, int count) {
if (keys) {
for (int i = 0; i < count; i++) {
free((void *)keys[i]);
}
free((void *)keys);
}
if (vals) {
free((void *)vals);
}
}
/* Resolve a class/type name through the registry. Returns borrowed QN or NULL. */
static const char *resolve_as_class(const cbm_registry_t *reg, const char *name,
const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count) {
cbm_resolution_t res =
cbm_registry_resolve(reg, name, module_qn, imp_keys, imp_vals, imp_count);
if (!res.qualified_name || res.qualified_name[0] == '\0') {
return NULL;
}
/* Verify it's a type-like container (Class/Struct/Interface/Enum/Type/Trait):
* a base/embedded type, impl receiver, or inheritance target must resolve to
* one of these. Struct included so Rust/Go/Swift/D `impl Trait for S` and Go
* struct embedding resolve. */
const char *label = cbm_registry_label_of(reg, res.qualified_name);
if (!cbm_label_is_type_like(label)) {
return NULL;
}
return res.qualified_name;
}
/* Extract decorator function name from the raw attribute/annotation text.
* Handles the per-language surface syntaxes:
* Python/TS: "@app.route('/api')" → "app.route"
* Java/Kotlin:"@Override" → "Override"
* C#: "[Log]" / "[Log(...)]" → "Log"
* Rust: "#[derive(Debug)]" → "derive"
* "#[allow(dead_code)]" → "allow"
* PHP 8: "#[Route('/users')]" → "Route"
* Swift: "@discardableResult" → "discardableResult"
* Scala: "@deprecated(...)" → "deprecated"
* Returns the leading identifier path, stopping at the first '(' or '['
* argument list and trimming any wrapping bracket/`@`/`#` punctuation. */
static void extract_decorator_func(const char *dec, char *out, size_t outsz) {
out[0] = '\0';
if (!dec) {
return;
}
const char *start = dec;
/* Strip leading sigils: '@' (Py/TS/JVM/Swift/Scala), '#' + '[' (Rust/PHP8),
* or a bare '[' (C# attribute). */
while (*start == '@' || *start == '#' || *start == '[' || *start == ' ') {
start++;
}
/* The name ends at the first argument list ('(' or '[') or wrapper ']'. */
size_t len = 0;
while (start[len] && start[len] != '(' && start[len] != '[' && start[len] != ']' &&
start[len] != ' ' && start[len] != ',') {
len++;
}
if (len == 0 || len >= outsz) {
return;
}
memcpy(out, start, len);
out[len] = '\0';
}
/* ── Go-style implicit interface satisfaction ─────────────────── */
/* Check if file_path ends with a suffix. */
static bool fp_ends_with(const char *fp, const char *suffix) {
if (!fp || !suffix) {
return false;
}
size_t fplen = strlen(fp);
size_t sflen = strlen(suffix);
return fplen >= sflen && strcmp(fp + fplen - sflen, suffix) == 0;
}
/* Info about one interface method (name + node ID). */
typedef struct {
const char *name;
int64_t id;
} go_imethod_t;
/* Check if class has all interface methods and create IMPLEMENTS + OVERRIDE edges. */
static int check_go_class_implements(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *cls,
const cbm_gbuf_node_t *iface, const go_imethod_t *imethods,
int im_count) {
if (!cls->file_path || !cls->qualified_name) {
return 0;
}
if (!fp_ends_with(cls->file_path, ".go")) {
return 0;
}
/* Resolve the struct's methods two ways and use whichever finds them:
* (a) the struct's DEFINES_METHOD edges, matched by method NAME — the real
* pipeline path, where Go receiver methods carry a flat QN (e.g.
* "pkg.Area" rather than "pkg.Circle.Area"), so QN-string
* reconstruction would miss; and
* (b) the reconstructed QN "<ClassQN>.<methodName>" — used by tests and any
* extractor that does emit class-qualified method QNs without
* DEFINES_METHOD edges from the class. */
const cbm_gbuf_edge_t **cls_dm = NULL;
int cls_dm_count = 0;
cbm_gbuf_find_edges_by_source_type(ctx->gbuf, cls->id, "DEFINES_METHOD", &cls_dm,
&cls_dm_count);
char prefix[CBM_SZ_512];
snprintf(prefix, sizeof(prefix), "%s.", cls->qualified_name);
/* For each interface method, find the matching struct method node. */
const cbm_gbuf_node_t *matched[CBM_SZ_128];
for (int m = 0; m < im_count; m++) {
const cbm_gbuf_node_t *found = NULL;
/* (a) DEFINES_METHOD edge by name */
for (int d = 0; d < cls_dm_count; d++) {
const cbm_gbuf_node_t *cm = cbm_gbuf_find_by_id(ctx->gbuf, cls_dm[d]->target_id);
if (cm && cm->name && strcmp(cm->name, imethods[m].name) == 0) {
found = cm;
break;
}
}
/* (b) reconstructed "<ClassQN>.<methodName>" */
if (!found) {
char method_qn[CBM_SZ_512];
snprintf(method_qn, sizeof(method_qn), "%s%s", prefix, imethods[m].name);
found = cbm_gbuf_find_by_qn(ctx->gbuf, method_qn);
}
if (!found) {
return 0; /* struct does not satisfy the interface */
}
matched[m] = found;
}
cbm_gbuf_insert_edge(ctx->gbuf, cls->id, iface->id, "IMPLEMENTS", "{}");
int edges = SKIP_ONE;
for (int m = 0; m < im_count; m++) {
cbm_gbuf_insert_edge(ctx->gbuf, matched[m]->id, imethods[m].id, "OVERRIDE", "{}");
edges++;
}
return edges;
}
int cbm_pipeline_implements_go(cbm_pipeline_ctx_t *ctx) {
int edge_count = 0;
/* Find all Interface nodes */
const cbm_gbuf_node_t **ifaces = NULL;
int iface_count = 0;
if (cbm_gbuf_find_by_label(ctx->gbuf, "Interface", &ifaces, &iface_count) != 0) {
return 0;
}
/* Find candidate concrete types. In Go the type that satisfies an interface
* is a struct (now labelled "Struct") or a named type (labelled "Class"); both
* sets are checked. Each call returns a borrowed internal array (no free). */
const cbm_gbuf_node_t **classes = NULL;
int class_count = 0;
cbm_gbuf_find_by_label(ctx->gbuf, "Class", &classes, &class_count);
const cbm_gbuf_node_t **structs = NULL;
int struct_count = 0;
cbm_gbuf_find_by_label(ctx->gbuf, "Struct", &structs, &struct_count);
if (class_count == 0 && struct_count == 0) {
return 0;
}
for (int i = 0; i < iface_count; i++) {
const cbm_gbuf_node_t *iface = ifaces[i];
if (!iface->file_path || !fp_ends_with(iface->file_path, ".go")) {
continue;
}
/* Get interface methods via DEFINES_METHOD edges */
const cbm_gbuf_edge_t **dm_edges = NULL;
int dm_count = 0;
if (cbm_gbuf_find_edges_by_source_type(ctx->gbuf, iface->id, "DEFINES_METHOD", &dm_edges,
&dm_count) != 0 ||
dm_count == 0) {
continue;
}
/* Collect interface method info */
go_imethod_t imethods[CBM_SZ_128];
int im_count = 0;
for (int j = 0; j < dm_count && im_count < CBM_SZ_128; j++) {
const cbm_gbuf_node_t *m = cbm_gbuf_find_by_id(ctx->gbuf, dm_edges[j]->target_id);
if (m && m->name) {
imethods[im_count++] = (go_imethod_t){m->name, m->id};
}
}
if (im_count == 0) {
continue;
}
/* Check each concrete-type node (Struct + Class) for method-set
* satisfaction. */
for (int c = 0; c < struct_count; c++) {
edge_count += check_go_class_implements(ctx, structs[c], iface, imethods, im_count);
}
for (int c = 0; c < class_count; c++) {
edge_count += check_go_class_implements(ctx, classes[c], iface, imethods, im_count);
}
}
return edge_count;
}
/* Build the QN of the synthetic node that stands in for an external/stdlib
* decorator whose target is not a local symbol (Rust `#[derive(Debug)]`, Swift
* `@discardableResult`, Scala `@deprecated`, Python `@cache`, Java `@Override`,
* ...). Namespaced with `<decorator:` so it can never collide with a real
* symbol and so all uses of the same decorator name across a project dedupe to
* one node by QN. */
static void synth_decorator_qn(const char *func_name, char *out, size_t outsz) {
snprintf(out, outsz, "<decorator:%s>", func_name);
}
/* Process INHERITS + DECORATES edges for one definition. */
/* Resolve one decorator and create DECORATES edge. */
static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *node,
const char *decorator, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, int *count) {
char func_name[CBM_SZ_256];
extract_decorator_func(decorator, func_name, sizeof(func_name));
if (func_name[0] == '\0') {
return;
}
cbm_resolution_t res =
cbm_registry_resolve(ctx->registry, func_name, module_qn, imp_keys, imp_vals, imp_count);
if ((!res.qualified_name || res.qualified_name[0] == '\0') && !strchr(func_name, '.')) {
/* C# attributes are referenced by their short name (`[Log]`) but declared
* with the conventional `Attribute` suffix (`class LogAttribute`). Retry
* with the suffix appended so the declaration resolves. */
char with_suffix[CBM_SZ_256];
int wn = snprintf(with_suffix, sizeof(with_suffix), "%sAttribute", func_name);
if (wn > 0 && (size_t)wn < sizeof(with_suffix)) {
res = cbm_registry_resolve(ctx->registry, with_suffix, module_qn, imp_keys, imp_vals,
imp_count);
}
}
const cbm_gbuf_node_t *dec = NULL;
if (res.qualified_name && res.qualified_name[0] != '\0') {
dec = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
}
if (!dec) {
/* The decorator target is not a local symbol (external attribute /
* stdlib annotation / proc-macro derive). Materialise a synthetic
* "Decorator" node so the DECORATES relation is still recorded.
* Upsert dedupes by QN, so every use of the same decorator name shares
* one node (one extra node per distinct external decorator, project-
* wide). Created in the single-threaded semantic pass, so direct
* ctx->gbuf mutation is safe here (mirrors pass_route_nodes). */
char syn_qn[CBM_SZ_512];
synth_decorator_qn(func_name, syn_qn, sizeof(syn_qn));
int64_t syn_id =
cbm_gbuf_upsert_node(ctx->gbuf, "Decorator", func_name, syn_qn, "", 0, 0, "{}");
if (syn_id != 0) {
dec = cbm_gbuf_find_by_qn(ctx->gbuf, syn_qn);
}
}
if (dec && node->id != dec->id) {
/* Decorator source text can contain quotes and raw newlines — escape
* it or the edge properties JSON is malformed (twin of the parallel
* path in pass_parallel.c). */
char esc_dec[CBM_SZ_256];
cbm_json_escape(esc_dec, sizeof(esc_dec), decorator);
char props[CBM_SZ_512];
snprintf(props, sizeof(props), "{\"decorator\":\"%s\"}", esc_dec);
cbm_gbuf_insert_edge(ctx->gbuf, node->id, dec->id, "DECORATES", props);
/* Ensure a reference edge exists so the decorator appears in usage queries
* without being misclassified as a real call by downstream passes. */
cbm_gbuf_insert_edge(ctx->gbuf, node->id, dec->id, "USAGE", "{}");
(*count)++;
}
}
static void sem_process_def_edges(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def,
const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, int *inherits_count,
int *decorates_count) {
if (!def->qualified_name) {
return;
}
const cbm_gbuf_node_t *node = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name);
if (!node) {
return;
}
if (def->base_classes) {
for (int b = 0; def->base_classes[b]; b++) {
const char *base_qn = resolve_as_class(ctx->registry, def->base_classes[b], module_qn,
imp_keys, imp_vals, imp_count);
if (!base_qn) {
continue;
}
const cbm_gbuf_node_t *base_node = cbm_gbuf_find_by_qn(ctx->gbuf, base_qn);
if (base_node && node->id != base_node->id) {
/* A base that resolves to an Interface is an IMPLEMENTS relation
* (Java `implements`, C# `: IFace`, TS `implements`); a Class/
* Type/Enum base is plain INHERITS. */
const char *base_label = cbm_registry_label_of(ctx->registry, base_qn);
const char *edge_type = (base_label && strcmp(base_label, "Interface") == 0)
? "IMPLEMENTS"
: "INHERITS";
cbm_gbuf_insert_edge(ctx->gbuf, node->id, base_node->id, edge_type, "{}");
(*inherits_count)++;
}
}
}
if (def->decorators) {
for (int dc = 0; def->decorators[dc]; dc++) {
resolve_decorator(ctx, node, def->decorators[dc], module_qn, imp_keys, imp_vals,
imp_count, decorates_count);
}
}
}
/* Get extraction result from cache or re-extract. Sets *owned=true if caller must free. */
static CBMFileResult *sem_get_or_extract(cbm_pipeline_ctx_t *ctx, int file_idx,
const cbm_file_info_t *fi, bool *owned) {
*owned = false;
if (ctx->result_cache && ctx->result_cache[file_idx]) {
return ctx->result_cache[file_idx];
}
int source_len = 0;
char *source = read_file(fi->path, &source_len);
if (!source) {
return NULL;
}
CBMFileResult *r = cbm_extract_file(source, source_len, fi->language, ctx->project_name,
fi->rel_path, CBM_EXTRACT_BUDGET, NULL, NULL);
free(source);
if (r) {
*owned = true;
}
return r;
}
/* Resolve Rust impl traits for one file's extraction results. */
static int resolve_impl_traits(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char *module_qn, const char **imp_keys, const char **imp_vals,
int imp_count) {
int count = 0;
for (int t = 0; t < result->impl_traits.count; t++) {
CBMImplTrait *it = &result->impl_traits.items[t];
if (!it->trait_name || !it->struct_name) {
continue;
}
const char *trait_qn = resolve_as_class(ctx->registry, it->trait_name, module_qn, imp_keys,
imp_vals, imp_count);
if (!trait_qn) {
continue;
}
const char *struct_qn = resolve_as_class(ctx->registry, it->struct_name, module_qn,
imp_keys, imp_vals, imp_count);
if (!struct_qn) {
continue;
}
const cbm_gbuf_node_t *tn = cbm_gbuf_find_by_qn(ctx->gbuf, trait_qn);
const cbm_gbuf_node_t *sn = cbm_gbuf_find_by_qn(ctx->gbuf, struct_qn);
if (tn && sn && tn->id != sn->id) {
cbm_gbuf_insert_edge(ctx->gbuf, sn->id, tn->id, "IMPLEMENTS", "{}");
count++;
}
}
return count;
}
int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count) {
cbm_log_info("pass.start", "pass", "semantic", "files", itoa_log(file_count));
int inherits_count = 0;
int decorates_count = 0;
int implements_count = 0;
int errors = 0;
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
return CBM_NOT_FOUND;
}
const char *rel = files[i].rel_path;
bool result_owned = false;
CBMFileResult *result = sem_get_or_extract(ctx, i, &files[i], &result_owned);
if (!result) {
errors++;
continue;
}
/* Build import map for resolution */
const char **imp_keys = NULL;
const char **imp_vals = NULL;
int imp_count = 0;
build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count);
char *module_qn = cbm_pipeline_fqn_module_dir(ctx->project_name, rel,
ps_module_is_dir(files[i].language));
/* ── INHERITS + DECORATES from definitions ──────────────── */
for (int d = 0; d < result->defs.count; d++) {
sem_process_def_edges(ctx, &result->defs.items[d], module_qn, imp_keys, imp_vals,
imp_count, &inherits_count, &decorates_count);
}
/* ── IMPLEMENTS from impl_traits (Rust) ─────────────────── */
implements_count +=
resolve_impl_traits(ctx, result, module_qn, imp_keys, imp_vals, imp_count);
free(module_qn);
free_import_map(imp_keys, imp_vals, imp_count);
if (result_owned) {
cbm_free_result(result);
}
}
/* ── Go-style implicit interface satisfaction ──────────────── */
int go_impl = cbm_pipeline_implements_go(ctx);
implements_count += go_impl;
cbm_log_info("pass.done", "pass", "semantic", "inherits", itoa_log(inherits_count), "decorates",
itoa_log(decorates_count), "implements", itoa_log(implements_count), "errors",
itoa_log(errors));
return 0;
}
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
/*
* pass_similarity.c — Generate SIMILAR_TO edges from MinHash fingerprints.
*
* Reads "fp" hex strings from Function/Method node properties,
* builds an LSH index, and emits SIMILAR_TO edges for pairs with
* Jaccard similarity ≥ threshold.
*
* Runs as a post-pass after enrichment (both full and incremental).
*/
#include "foundation/constants.h"
#include "pipeline/pipeline.h"
#include <stdint.h>
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "simhash/minhash.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "foundation/profile.h"
#include "foundation/platform.h"
enum {
SIM_EDGE_INIT_CAP = 256,
SIM_EDGE_GROW = 2,
};
#include "pipeline/worker_pool.h"
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum { FP_KEY_PREFIX_LEN = 6, MIN_FP_ENTRIES = 2 }; /* strlen("\"fp\":\"") */
/* ── Helpers ─────────────────────────────────────────────────────── */
/* Extract file extension from a path (including the dot). */
static const char *file_ext(const char *path) {
if (!path) {
return "";
}
const char *dot = strrchr(path, '.');
return dot ? dot : "";
}
/* Parse "fp" hex string from a node's properties_json.
* Returns true if found and decoded successfully. */
static bool parse_fp_from_props(const char *props_json, cbm_minhash_t *out) {
if (!props_json) {
return false;
}
const char *fp_key = strstr(props_json, "\"fp\":\"");
if (!fp_key) {
return false;
}
const char *hex_start = fp_key + FP_KEY_PREFIX_LEN;
/* Find closing quote */
const char *hex_end = strchr(hex_start, '"');
if (!hex_end) {
return false;
}
int hex_len = (int)(hex_end - hex_start);
if (hex_len != CBM_MINHASH_HEX_LEN) {
return false;
}
char hex_buf[CBM_MINHASH_HEX_BUF];
memcpy(hex_buf, hex_start, (size_t)hex_len);
hex_buf[hex_len] = '\0';
return cbm_minhash_from_hex(hex_buf, out);
}
/* Log helper for integer-to-string in log calls. */
static const char *itoa_log(int val) {
enum { RING_BUF_COUNT = 4, RING_BUF_MASK = 3 };
static CBM_TLS char bufs[RING_BUF_COUNT][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & RING_BUF_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* ── Internal types ──────────────────────────────────────────────── */
enum { FP_ENTRY_INIT_CAP = 256, FP_ENTRY_GROW = 2, PROPS_BUF_LEN = 256 };
typedef struct {
int64_t node_id;
cbm_minhash_t fp;
const char *file_path;
const char *ext;
const char *qn; /* canonical ordering + pair-ownership (determinism) */
} fp_entry_t;
/* Canonical entry order: by qualified name (unique). The label-index order
* from collect_fp_entries is gbuf insertion order = parallel-extraction merge
* order, which varies run to run; LSH bucket chains and the per-node edge-cap
* truncation both inherit it, flickering WHICH SIMILAR_TO edges are emitted. */
static int cmp_fp_entry_by_qn(const void *pa, const void *pb) {
const fp_entry_t *a = pa;
const fp_entry_t *b = pb;
const char *qa = a->qn ? a->qn : "";
const char *qb = b->qn ? b->qn : "";
int r = strcmp(qa, qb);
if (r != 0) {
return r;
}
if (a->node_id != b->node_id) {
return a->node_id < b->node_id ? -1 : 1;
}
return 0;
}
/* Collect all Function/Method nodes with fingerprints from graph buffer. */
static int collect_fp_entries(cbm_gbuf_t *gbuf, fp_entry_t **out_entries) {
fp_entry_t *entries = NULL;
int count = 0;
int cap = 0;
const char *labels[] = {"Function", "Method", NULL};
for (int li = 0; labels[li]; li++) {
const cbm_gbuf_node_t **nodes = NULL;
int node_count = 0;
if (cbm_gbuf_find_by_label(gbuf, labels[li], &nodes, &node_count) != 0) {
continue;
}
for (int i = 0; i < node_count; i++) {
const cbm_gbuf_node_t *n = nodes[i];
cbm_minhash_t fp;
if (!parse_fp_from_props(n->properties_json, &fp)) {
continue;
}
if (count >= cap) {
int new_cap = cap < FP_ENTRY_INIT_CAP ? FP_ENTRY_INIT_CAP : cap * FP_ENTRY_GROW;
fp_entry_t *grown = realloc(entries, (size_t)new_cap * sizeof(fp_entry_t));
if (!grown) {
break;
}
entries = grown;
cap = new_cap;
}
entries[count++] = (fp_entry_t){
.node_id = n->id,
.fp = fp,
.file_path = n->file_path,
.ext = file_ext(n->file_path),
.qn = n->qualified_name,
};
}
}
/* Canonicalize (determinism) — see cmp_fp_entry_by_qn. */
qsort(entries, (size_t)count, sizeof(fp_entry_t), cmp_fp_entry_by_qn);
*out_entries = entries;
return count;
}
/* ── Parallel query + emit ────────────────────────────────────────── */
/* Deferred edge record; collected per-worker, merged into gbuf sequentially. */
typedef struct {
int64_t source_id;
int64_t target_id;
double jaccard;
bool same_file;
} sim_deferred_edge_t;
typedef struct {
sim_deferred_edge_t *edges;
int count;
int cap;
} sim_edge_buf_t;
static void sim_edge_buf_push(sim_edge_buf_t *buf, int64_t src, int64_t tgt, double jaccard,
bool same_file) {
if (buf->count >= buf->cap) {
int nc = buf->cap < SIM_EDGE_INIT_CAP ? SIM_EDGE_INIT_CAP : buf->cap * SIM_EDGE_GROW;
sim_deferred_edge_t *grown = realloc(buf->edges, (size_t)nc * sizeof(sim_deferred_edge_t));
if (!grown) {
return;
}
buf->edges = grown;
buf->cap = nc;
}
buf->edges[buf->count++] = (sim_deferred_edge_t){
.source_id = src, .target_id = tgt, .jaccard = jaccard, .same_file = same_file};
}
typedef struct {
const fp_entry_t *entries;
int entry_count;
const cbm_lsh_index_t *lsh;
sim_edge_buf_t *worker_bufs;
_Atomic int next_idx;
_Atomic int *edge_counts; /* shared atomic array, one per entry */
} sim_query_ctx_t;
enum { SIM_CAND_CAP = 4096 };
static void sim_query_worker(int worker_id, void *ctx_ptr) {
sim_query_ctx_t *sc = ctx_ptr;
sim_edge_buf_t *my_buf = &sc->worker_bufs[worker_id];
/* Thread-local candidate buffer (stack-allocated) */
const cbm_lsh_entry_t *cands[SIM_CAND_CAP];
while (true) {
int i = atomic_fetch_add_explicit(&sc->next_idx, SKIP_ONE, memory_order_relaxed);
if (i >= sc->entry_count) {
break;
}
int ec = atomic_load_explicit(&sc->edge_counts[i], memory_order_relaxed);
if (ec >= CBM_MINHASH_MAX_EDGES_PER_NODE) {
continue;
}
const fp_entry_t *src = &sc->entries[i];
int cand_count = cbm_lsh_query_into(sc->lsh, &src->fp, cands, SIM_CAND_CAP);
int emitted = 0;
for (int c = 0; c < cand_count; c++) {
const cbm_lsh_entry_t *cand = cands[c];
if (cand->node_id == src->node_id) {
continue;
}
if (strcmp(src->ext, cand->file_ext) != 0) {
continue;
}
/* Pair ownership by canonical QN order, not node id: ids are
* assigned in parallel-merge order and vary run to run, which
* flipped which side owned a pair and (with the per-source edge
* cap) flickered the emitted set (determinism). */
if (!src->qn || !cand->qualified_name || strcmp(src->qn, cand->qualified_name) >= 0) {
continue;
}
int cur = atomic_load_explicit(&sc->edge_counts[i], memory_order_relaxed);
if (cur + emitted >= CBM_MINHASH_MAX_EDGES_PER_NODE) {
break;
}
double jaccard = cbm_minhash_jaccard(&src->fp, cand->fingerprint);
if (jaccard < CBM_MINHASH_JACCARD_THRESHOLD) {
continue;
}
bool same_file =
src->file_path && cand->file_path && strcmp(src->file_path, cand->file_path) == 0;
sim_edge_buf_push(my_buf, src->node_id, cand->node_id, jaccard, same_file);
emitted++;
}
if (emitted > 0) {
atomic_fetch_add_explicit(&sc->edge_counts[i], emitted, memory_order_relaxed);
}
}
}
/* Merge worker edge buffers into gbuf. Returns total edge count. Frees worker buffers. */
static int merge_sim_edges(cbm_gbuf_t *gbuf, sim_edge_buf_t *worker_bufs, int worker_count) {
int total = 0;
for (int w = 0; w < worker_count; w++) {
for (int e = 0; e < worker_bufs[w].count; e++) {
sim_deferred_edge_t *de = &worker_bufs[w].edges[e];
char props[PROPS_BUF_LEN];
snprintf(props, sizeof(props), "{\"jaccard\":%.3f,\"same_file\":%s}", de->jaccard,
de->same_file ? "true" : "false");
cbm_gbuf_insert_edge(gbuf, de->source_id, de->target_id, "SIMILAR_TO", props);
total++;
}
free(worker_bufs[w].edges);
}
free(worker_bufs);
return total;
}
/* ── Pass entry point ────────────────────────────────────────────── */
int cbm_pipeline_pass_similarity(cbm_pipeline_ctx_t *ctx) {
cbm_log_info("pass.start", "pass", "similarity");
cbm_gbuf_t *gbuf = ctx->gbuf;
/* Phase 1: Collect fingerprints from Function/Method nodes */
CBM_PROF_START(t_collect);
fp_entry_t *entries = NULL;
int entry_count = collect_fp_entries(gbuf, &entries);
CBM_PROF_END_N("similarity", "1_collect_fp", t_collect, entry_count);
cbm_log_info("pass.similarity.collected", "nodes_with_fp", itoa_log(entry_count));
if (entry_count < MIN_FP_ENTRIES) {
free(entries);
cbm_log_info("pass.done", "pass", "similarity", "edges", "0");
return 0;
}
/* Phase 2: Build LSH index (sequential — cbm_lsh_insert mutates shared state) */
CBM_PROF_START(t_lsh_build);
cbm_lsh_index_t *lsh = cbm_lsh_new();
cbm_lsh_entry_t *lsh_entries = malloc((size_t)entry_count * sizeof(cbm_lsh_entry_t));
if (!lsh_entries) {
free(entries);
cbm_lsh_free(lsh);
return CBM_NOT_FOUND;
}
for (int i = 0; i < entry_count; i++) {
lsh_entries[i] = (cbm_lsh_entry_t){
.node_id = entries[i].node_id,
.fingerprint = &entries[i].fp,
.file_path = entries[i].file_path,
.file_ext = entries[i].ext,
.qualified_name = entries[i].qn,
};
cbm_lsh_insert(lsh, &lsh_entries[i]);
}
CBM_PROF_END_N("similarity", "2_lsh_build_seq", t_lsh_build, entry_count);
/* Phase 3: Query LSH + emit edges (PARALLEL via cbm_lsh_query_into).
* Each worker claims entries, queries, scores candidates, stashes edges
* in its own deferred buffer. Shared edge_counts is atomic.
* Final merge into gbuf is sequential (gbuf not thread-safe). */
CBM_PROF_START(t_query_emit);
_Atomic int *edge_counts = calloc((size_t)entry_count, sizeof(_Atomic int));
int worker_count = cbm_default_worker_count(false);
sim_edge_buf_t *worker_bufs = calloc((size_t)worker_count, sizeof(sim_edge_buf_t));
{
sim_query_ctx_t sc = {
.entries = entries,
.entry_count = entry_count,
.lsh = lsh,
.worker_bufs = worker_bufs,
.edge_counts = edge_counts,
};
atomic_init(&sc.next_idx, 0);
cbm_parallel_for_opts_t opts = {.max_workers = worker_count, .force_pthreads = false};
cbm_parallel_for(worker_count, sim_query_worker, &sc, opts);
}
CBM_PROF_END_N("similarity", "3_query_parallel", t_query_emit, entry_count);
CBM_PROF_START(t_merge);
int total_edges = merge_sim_edges(gbuf, worker_bufs, worker_count);
CBM_PROF_END_N("similarity", "4_edge_merge_seq", t_merge, total_edges);
cbm_log_info("pass.done", "pass", "similarity", "edges", itoa_log(total_edges));
free(edge_counts);
free(lsh_entries);
free(entries);
cbm_lsh_free(lsh);
return 0;
}
+296
View File
@@ -0,0 +1,296 @@
/*
* pass_tests.c — Create TESTS edges from test functions to production functions.
*
* Scans CALLS edges in the graph buffer: if the source function has
* is_test=true and the target does not, creates a TESTS edge.
*
* Also creates TESTS_FILE edges from test File nodes to the production
* File nodes they correspond to (naming convention: _test.go → .go, etc.)
*
* Depends on: pass_calls having populated CALLS edges
*/
#include "foundation/constants.h"
enum {
PT_RING = 4,
PT_RING_MASK = 3,
PT_TEST_LEN = 4, /* strlen("Test") */
PT_DESCRIBE_LEN = 9, /* strlen("Describe") + 1... actually strlen("describe_") */
PT_CONTEXT_LEN = 7, /* strlen("context") */
PT_SUFFIX_LEN = 8, /* strlen("_test.go") */
PT_EXT_PY = 3, /* strlen(".py") / strlen(".go") */
PT_PYTEST_PREFIX = 5, /* strlen("test_") */
PT_SEARCH_COUNT = 2, /* number of search passes */
PT_FOUND_SKIP = 5, /* strlen("_test") + 1 or strlen("_spec") + 1 */
};
#define SLEN(s) (sizeof(s) - 1)
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *itoa_log(int val) {
static char bufs[PT_RING][CBM_SZ_32];
static int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & PT_RING_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Check if a node has is_test:true in its properties JSON. */
static bool node_is_test(const cbm_gbuf_node_t *n) {
if (!n || !n->properties_json) {
return false;
}
return strstr(n->properties_json, "\"is_test\":true") != NULL;
}
/* Helper to check suffix. */
static bool str_ends_with(const char *s, size_t slen, const char *suffix) {
size_t sflen = strlen(suffix);
return slen >= sflen && strcmp(s + slen - sflen, suffix) == 0;
}
/* Check if a file path looks like a test file (language-agnostic). */
bool cbm_is_test_path(const char *path) {
if (!path) {
return false;
}
const char *base = strrchr(path, '/');
base = base ? base + SKIP_ONE : path;
size_t len = strlen(path);
/* Prefix-based: Python test_*.py */
if (strncmp(base, "test_", SLEN("test_")) == 0) {
return true;
}
/* Suffix-based: _test.<ext> pattern (Go, Python, Rust, C++, Lua) */
if (str_ends_with(path, len, "_test.go") || str_ends_with(path, len, "_test.py") ||
str_ends_with(path, len, "_test.rs") || str_ends_with(path, len, "_test.cpp") ||
str_ends_with(path, len, "_test.lua")) {
return true;
}
/* .test.<ext> / .spec.<ext> pattern (JS/TS/TSX) */
if (strstr(path, ".test.ts") || strstr(path, ".spec.ts") || strstr(path, ".test.js") ||
strstr(path, ".spec.js") || strstr(path, ".test.tsx") || strstr(path, ".spec.tsx")) {
return true;
}
/* Name ends with "Test" or "Spec" before extension (Java, Kotlin, C#, PHP, Scala) */
if (str_ends_with(path, len, "Test.java") || str_ends_with(path, len, "Test.kt") ||
str_ends_with(path, len, "Test.cs") || str_ends_with(path, len, "Test.php") ||
str_ends_with(path, len, "Spec.scala")) {
return true;
}
/* Directory-based: __tests__/, tests/, test/, spec/ */
if (strstr(path, "__tests__/") || strstr(path, "/tests/") || strstr(path, "/test/") ||
strstr(path, "/spec/")) {
return true;
}
/* Also match if path STARTS with these directories */
if (strncmp(path, "tests/", SLEN("tests/")) == 0 ||
strncmp(path, "test/", SLEN("test/")) == 0 || strncmp(path, "spec/", SLEN("spec/")) == 0 ||
strncmp(path, "__tests__/", SLEN("__tests__/")) == 0) {
return true;
}
/* Ruby: _spec.rb suffix */
if (str_ends_with(path, len, "_spec.rb")) {
return true;
}
return false;
}
/* Check if a function name looks like a test function (language-agnostic). */
bool cbm_is_test_func_name(const char *name) {
if (!name) {
return false;
}
/* Go: Test/Benchmark/Example + uppercase letter or end-of-string.
* "TestFoo" = test, "Testable" = not test (lowercase after prefix). */
if (strncmp(name, "Test", SLEN("Test")) == 0 &&
(name[PT_TEST_LEN] == '\0' || (name[PT_TEST_LEN] >= 'A' && name[PT_TEST_LEN] <= 'Z'))) {
return true;
}
if (strncmp(name, "Benchmark", SLEN("Benchmark")) == 0 &&
(name[PT_DESCRIBE_LEN] == '\0' ||
(name[PT_DESCRIBE_LEN] >= 'A' && name[PT_DESCRIBE_LEN] <= 'Z'))) {
return true;
}
if (strncmp(name, "Example", SLEN("Example")) == 0 &&
(name[PT_CONTEXT_LEN] == '\0' ||
(name[PT_CONTEXT_LEN] >= 'A' && name[PT_CONTEXT_LEN] <= 'Z'))) {
return true;
}
/* Python/Rust/C++/Lua/Java: test_ or test prefix (lowercase) */
if (strncmp(name, "test_", SLEN("test_")) == 0) {
return true;
}
if (strncmp(name, "test", SLEN("test")) == 0 && name[PT_TEST_LEN] >= 'A' &&
name[PT_TEST_LEN] <= 'Z') {
return true;
}
/* JS/TS: test/it/describe + lifecycle hooks */
if (strcmp(name, "test") == 0 || strcmp(name, "it") == 0 || strcmp(name, "describe") == 0 ||
strcmp(name, "beforeAll") == 0 || strcmp(name, "afterAll") == 0 ||
strcmp(name, "beforeEach") == 0 || strcmp(name, "afterEach") == 0) {
return true;
}
/* Julia: @testset, @test */
if (strcmp(name, "@testset") == 0 || strcmp(name, "@test") == 0) {
return true;
}
return false;
}
/* Assemble dir/name+ext into a heap-allocated path. */
static char *build_path(const char *test_path, size_t dir_len, const char *name, size_t name_len,
const char *ext, size_t ext_len) {
char *result = malloc(dir_len + SKIP_ONE + name_len + ext_len + SKIP_ONE);
if (dir_len > 0) {
memcpy(result, test_path, dir_len);
result[dir_len] = '/';
memcpy(result + dir_len + SKIP_ONE, name, name_len);
memcpy(result + dir_len + SKIP_ONE + name_len, ext, ext_len + SKIP_ONE);
} else {
memcpy(result, name, name_len);
memcpy(result + name_len, ext, ext_len + SKIP_ONE);
}
return result;
}
/* Try to derive the production file path from a test file path.
* Returns heap-allocated string or NULL. Caller must free(). */
static char *test_to_prod_path(const char *test_path) {
if (!test_path) {
return NULL;
}
const char *base = strrchr(test_path, '/');
size_t dir_len = base ? (size_t)(base - test_path) : 0;
base = base ? base + SKIP_ONE : test_path;
/* Go: foo_test.go → foo.go */
const char *suffix = strstr(base, "_test.go");
if (suffix && suffix[PT_SUFFIX_LEN] == '\0') {
return build_path(test_path, dir_len, base, (size_t)(suffix - base), ".go", PT_EXT_PY);
}
/* Python: test_foo.py → foo.py */
if (strncmp(base, "test_", SLEN("test_")) == 0) {
const char *ext = strstr(base, ".py");
if (ext && ext[PT_EXT_PY] == '\0') {
return build_path(test_path, dir_len, base + PT_PYTEST_PREFIX,
(size_t)(ext - base - PT_PYTEST_PREFIX), ".py", PT_EXT_PY);
}
}
/* JS/TS: foo.test.ts → foo.ts, foo.spec.ts → foo.ts */
for (int s = 0; s < PT_SEARCH_COUNT; s++) {
const char *pat = s == 0 ? ".test." : ".spec.";
const char *found = strstr(base, pat);
if (found) {
const char *ext = found + PT_FOUND_SKIP;
return build_path(test_path, dir_len, base, (size_t)(found - base), ext, strlen(ext));
}
}
return NULL;
}
/* Create TESTS edges from test functions to production functions via CALLS edges. */
static int create_tests_edges(cbm_pipeline_ctx_t *ctx) {
int count = 0;
const cbm_gbuf_edge_t **call_edges = NULL;
int call_count = 0;
int rc = cbm_gbuf_find_edges_by_type(ctx->gbuf, "CALLS", &call_edges, &call_count);
if (rc != 0 || call_count == 0) {
return 0;
}
for (int i = 0; i < call_count; i++) {
const cbm_gbuf_edge_t *e = call_edges[i];
const cbm_gbuf_node_t *src = cbm_gbuf_find_by_id(ctx->gbuf, e->source_id);
const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_id(ctx->gbuf, e->target_id);
if (!src || !tgt) {
continue;
}
bool src_is_test =
node_is_test(src) || (src->file_path && cbm_is_test_path(src->file_path));
if (!src_is_test) {
continue;
}
bool tgt_is_test =
node_is_test(tgt) || (tgt->file_path && cbm_is_test_path(tgt->file_path));
if (tgt_is_test) {
continue;
}
if (!cbm_is_test_func_name(src->name)) {
continue;
}
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, "TESTS", "{}");
count++;
}
return count;
}
/* Create TESTS_FILE edges from test File nodes to production File nodes. */
static int create_tests_file_edges(cbm_pipeline_ctx_t *ctx) {
int count = 0;
const cbm_gbuf_node_t **file_nodes = NULL;
int file_node_count = 0;
int rc = cbm_gbuf_find_by_label(ctx->gbuf, "File", &file_nodes, &file_node_count);
if (rc != 0) {
return 0;
}
for (int i = 0; i < file_node_count; i++) {
const cbm_gbuf_node_t *fnode = file_nodes[i];
if (!fnode->file_path || !cbm_is_test_path(fnode->file_path)) {
continue;
}
char *prod_path = test_to_prod_path(fnode->file_path);
if (!prod_path) {
continue;
}
char *prod_qn = cbm_pipeline_fqn_compute(ctx->project_name, prod_path, "__file__");
const cbm_gbuf_node_t *prod_node = cbm_gbuf_find_by_qn(ctx->gbuf, prod_qn);
free(prod_qn);
free(prod_path);
if (prod_node && fnode->id != prod_node->id) {
cbm_gbuf_insert_edge(ctx->gbuf, fnode->id, prod_node->id, "TESTS_FILE", "{}");
count++;
}
}
return count;
}
int cbm_pipeline_pass_tests(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count) {
cbm_log_info("pass.start", "pass", "tests");
(void)files;
(void)file_count;
int tests_count = create_tests_edges(ctx);
int tests_file_count = create_tests_file_edges(ctx);
cbm_log_info("pass.done", "pass", "tests", "tests", itoa_log(tests_count), "tests_file",
itoa_log(tests_file_count));
return 0;
}
+392
View File
@@ -0,0 +1,392 @@
/*
* pass_usages.c — Resolve usages, throws, and read/write edges.
*
* For each file, re-extracts and resolves:
* - USAGE edges: identifier references (not calls) to registered symbols
* - THROWS/RAISES edges: exception types
* - READS/WRITES edges: variable read/write access patterns
*
* All three use the same registry lookup strategy. Combined into one pass
* to avoid triple re-extraction.
*
* Depends on: pass_definitions having populated the registry and graph buffer
*/
#include "foundation/constants.h"
#include "foundation/str_util.h" // cbm_json_escape
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include "graph_buffer/graph_buffer.h"
#include "foundation/log.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "cbm.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* True for languages whose module QN derives from the CONTAINING DIRECTORY
* (Java/Go package). MUST match cbm_lang_module_is_dir() (internal/cbm/helpers.c)
* so same-module resolution keys against the directory-based def-node QNs. */
static bool pu_module_is_dir(CBMLanguage lang) {
return lang == CBM_LANG_JAVA || lang == CBM_LANG_GO;
}
/* Read file into heap buffer. Caller must free(). */
static char *read_file(const char *path, int *out_len) {
FILE *f = cbm_fopen(path, "rb");
if (!f) {
return NULL;
}
(void)fseek(f, 0, SEEK_END);
long size = ftell(f);
(void)fseek(f, 0, SEEK_SET);
if (size <= 0 || size > cbm_max_file_bytes()) { /* generous, env-configurable cap (B4) */
(void)fclose(f);
return NULL;
}
/* +pad: tree-sitter lexer lookahead reads past EOF; keep it in-bounds */
enum { CBM_TS_LOOKAHEAD_PAD = 16 };
char *buf = malloc((size_t)size + CBM_TS_LOOKAHEAD_PAD);
if (!buf) {
(void)fclose(f);
return NULL;
}
size_t nread = fread(buf, SKIP_ONE, size, f);
(void)fclose(f);
if (nread > (size_t)size) {
nread = (size_t)size;
}
memset(buf + nread, 0, CBM_TS_LOOKAHEAD_PAD);
*out_len = (int)nread;
return buf;
}
static const char *itoa_log(int val) {
enum { RING_BUF_COUNT = 4, RING_BUF_MASK = 3 };
static CBM_TLS char bufs[RING_BUF_COUNT][CBM_SZ_32];
static CBM_TLS int idx = 0;
int i = idx;
idx = (idx + SKIP_ONE) & RING_BUF_MASK;
snprintf(bufs[i], sizeof(bufs[i]), "%d", val);
return bufs[i];
}
/* Check if an exception name is a "checked" exception (Java-style).
* Checked: Exception, IOException, etc. (extends Exception, not RuntimeException).
* Simple heuristic: if name contains "Error" or "Panic", it's a runtime exception. */
static bool is_checked_exception(const char *name) {
if (!name) {
return false;
}
if (strstr(name, "Error") || strstr(name, "Panic") || strstr(name, "error") ||
strstr(name, "panic")) {
return false;
}
return true; /* Default: treat as checked */
}
/* Build import map from cached extraction result (fast path). */
static int build_import_map_from_cache(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char ***out_keys, const char ***out_vals,
int *out_count) {
const char **keys = calloc((size_t)result->imports.count, sizeof(const char *));
const char **vals = calloc((size_t)result->imports.count, sizeof(const char *));
int count = 0;
for (int i = 0; i < result->imports.count; i++) {
const CBMImport *imp = &result->imports.items[i];
if (!imp->local_name || !imp->local_name[0] || !imp->module_path) {
continue;
}
char *target_qn = cbm_pipeline_fqn_module(ctx->project_name, imp->module_path);
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
free(target_qn);
if (!target) {
continue;
}
keys[count] = strdup(imp->local_name);
vals[count] = target->qualified_name;
count++;
}
*out_keys = keys;
*out_vals = vals;
*out_count = count;
return 0;
}
/* Build import map from graph buffer IMPORTS edges (slow path). */
static int build_import_map_from_edges(cbm_pipeline_ctx_t *ctx, const char *rel_path,
const char ***out_keys, const char ***out_vals,
int *out_count) {
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
free(file_qn);
if (!file_node) {
return 0;
}
const cbm_gbuf_edge_t **edges = NULL;
int edge_count = 0;
int rc = cbm_gbuf_find_edges_by_source_type(ctx->gbuf, file_node->id, "IMPORTS", &edges,
&edge_count);
if (rc != 0 || edge_count == 0) {
return 0;
}
const char **keys = calloc(edge_count, sizeof(const char *));
const char **vals = calloc(edge_count, sizeof(const char *));
int count = 0;
for (int i = 0; i < edge_count; i++) {
const cbm_gbuf_edge_t *e = edges[i];
const cbm_gbuf_node_t *target = cbm_gbuf_find_by_id(ctx->gbuf, e->target_id);
if (!target || !e->properties_json) {
continue;
}
const char *start = strstr(e->properties_json, "\"local_name\":\"");
if (start) {
start += strlen("\"local_name\":\"");
const char *end = strchr(start, '"');
if (end && end > start) {
keys[count] = cbm_strndup(start, end - start);
vals[count] = target->qualified_name;
count++;
}
}
}
*out_keys = keys;
*out_vals = vals;
*out_count = count;
return 0;
}
/* Build per-file import map from cached extraction result or graph buffer edges. */
static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path,
const CBMFileResult *result, const char ***out_keys,
const char ***out_vals, int *out_count) {
*out_keys = NULL;
*out_vals = NULL;
*out_count = 0;
if (result && result->imports.count > 0) {
return build_import_map_from_cache(ctx, result, out_keys, out_vals, out_count);
}
return build_import_map_from_edges(ctx, rel_path, out_keys, out_vals, out_count);
}
static void free_import_map(const char **keys, const char **vals, int count) {
if (keys) {
for (int i = 0; i < count; i++) {
free((void *)keys[i]);
}
free((void *)keys);
}
if (vals) {
free((void *)vals);
}
}
/* Find the graph buffer node for an enclosing function QN, falling back to file node. */
static const cbm_gbuf_node_t *find_enclosing_node(cbm_pipeline_ctx_t *ctx, const char *func_qn,
const char *rel_path) {
const cbm_gbuf_node_t *node = NULL;
if (func_qn && func_qn[0]) {
node = cbm_gbuf_find_by_qn(ctx->gbuf, func_qn);
/* A class-level reference in a directory-module language carries the
* DIRECTORY module QN, which hits the shared Folder/Project node —
* attribute to this file's File node instead (#787). */
if (cbm_pipeline_node_is_dir_container(node)) {
node = NULL;
}
}
if (!node) {
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__");
node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
free(file_qn);
}
return node;
}
/* Resolve USAGE edges for one file's extracted usages. */
static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count) {
int resolved = 0;
for (int u = 0; u < result->usages.count; u++) {
CBMUsage *usage = &result->usages.items[u];
if (!usage->ref_name) {
continue;
}
const cbm_gbuf_node_t *src = find_enclosing_node(ctx, usage->enclosing_func_qn, rel);
if (!src) {
continue;
}
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, usage->ref_name, module_qn,
imp_keys, imp_vals, imp_count);
if (!res.qualified_name || res.qualified_name[0] == '\0') {
continue;
}
const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
if (!tgt || src->id == tgt->id) {
continue;
}
/* ref_name is sliced source text and can contain quotes/newlines —
* escape it or the edge properties JSON is malformed. */
char esc_ref[CBM_SZ_256];
cbm_json_escape(esc_ref, sizeof(esc_ref), usage->ref_name);
char uprops[CBM_SZ_512];
snprintf(uprops, sizeof(uprops), "{\"callee\":\"%s\"}", esc_ref);
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, "USAGE", uprops);
resolved++;
}
return resolved;
}
/* Resolve THROWS/RAISES edges for one file's extracted throws. */
static int resolve_throw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result,
const char *rel, const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count) {
int resolved = 0;
for (int t = 0; t < result->throws.count; t++) {
CBMThrow *thr = &result->throws.items[t];
if (!thr->exception_name || !thr->enclosing_func_qn) {
continue;
}
const cbm_gbuf_node_t *src = find_enclosing_node(ctx, thr->enclosing_func_qn, rel);
if (!src) {
continue;
}
const char *edge_type = is_checked_exception(thr->exception_name) ? "THROWS" : "RAISES";
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, thr->exception_name, module_qn,
imp_keys, imp_vals, imp_count);
const cbm_gbuf_node_t *tgt = NULL;
if (res.qualified_name && res.qualified_name[0]) {
tgt = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
}
if (!tgt || src->id == tgt->id) {
continue;
}
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}");
resolved++;
}
return resolved;
}
/* Resolve READS/WRITES edges for one file's extracted read/write accesses. */
static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *rel,
const char *module_qn, const char **imp_keys, const char **imp_vals,
int imp_count) {
int resolved = 0;
for (int r = 0; r < result->rw.count; r++) {
CBMReadWrite *rw = &result->rw.items[r];
if (!rw->var_name) {
continue;
}
const cbm_gbuf_node_t *src = find_enclosing_node(ctx, rw->enclosing_func_qn, rel);
if (!src) {
continue;
}
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, rw->var_name, module_qn,
imp_keys, imp_vals, imp_count);
if (!res.qualified_name || res.qualified_name[0] == '\0') {
continue;
}
const cbm_gbuf_node_t *tgt = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
if (!tgt || src->id == tgt->id) {
continue;
}
const char *edge_type = rw->is_write ? "WRITES" : "READS";
cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}");
resolved++;
}
return resolved;
}
int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count) {
cbm_log_info("pass.start", "pass", "usages", "files", itoa_log(file_count));
int usage_resolved = 0;
int throw_resolved = 0;
int rw_resolved = 0;
int errors = 0;
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
return CBM_NOT_FOUND;
}
const char *path = files[i].path;
const char *rel = files[i].rel_path;
CBMFileResult *result = NULL;
bool result_owned = false;
if (ctx->result_cache) {
result = ctx->result_cache[i];
}
if (!result) {
int source_len = 0;
char *source = read_file(path, &source_len);
if (!source) {
errors++;
continue;
}
result = cbm_extract_file(source, source_len, files[i].language, ctx->project_name, rel,
CBM_EXTRACT_BUDGET, NULL, NULL);
free(source);
if (!result) {
errors++;
continue;
}
result_owned = true;
}
if (result->usages.count == 0 && result->throws.count == 0 && result->rw.count == 0) {
if (result_owned) {
cbm_free_result(result);
}
continue;
}
const char **imp_keys = NULL;
const char **imp_vals = NULL;
int imp_count = 0;
build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count);
char *module_qn = cbm_pipeline_fqn_module_dir(ctx->project_name, rel,
pu_module_is_dir(files[i].language));
usage_resolved +=
resolve_usage_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count);
throw_resolved +=
resolve_throw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count);
rw_resolved += resolve_rw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count);
free(module_qn);
free_import_map(imp_keys, imp_vals, imp_count);
if (result_owned) {
cbm_free_result(result);
}
}
cbm_log_info("pass.done", "pass", "usages", "usage", itoa_log(usage_resolved), "throws",
itoa_log(throw_resolved), "rw", itoa_log(rw_resolved), "errors", itoa_log(errors));
return 0;
}
+513
View File
@@ -0,0 +1,513 @@
/*
* path_alias.c — Resolve build-tool path aliases.
*
* Builds a directory-scoped collection of alias maps from per-language
* config files (currently tsconfig.json / jsconfig.json) so the import
* resolver can turn "@/lib/auth"-style imports into repo-relative paths.
*
* Design notes:
* - Public types and functions are language-agnostic. Adding a Vite /
* Webpack / Python loader means writing a new load_*_file() helper
* and registering it in find_alias_files. The resolver, the
* collection, and the pipeline integration do not change.
* - Sorting uses qsort (n log n). The bubble-sorts that the original
* Layer 1b draft used were O(n^2); with up to 256 alias entries
* and 256 scoped maps per repo, qsort is the right ceiling.
* - The repo walk caps recursion depth and total file count and emits
* a warning when either cap fires, so silent truncation on
* pathological monorepos shows up in the index log.
*/
#include "pipeline/path_alias.h"
#include "pipeline/pipeline_internal.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/platform.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <yyjson/yyjson.h>
/* Resource ceilings. Chosen to comfortably cover real-world monorepos
* (Next.js Skyline, large nx workspaces) while bounding worst-case
* memory and walk time. Cap hits are logged. */
enum {
CBM_PATH_ALIAS_MAX_ENTRIES = 256, /* per single config file */
CBM_PATH_ALIAS_MAX_FILES = 256, /* config files per repo walk */
CBM_PATH_ALIAS_MAX_FILE_BYTES = 64 * 1024,
CBM_PATH_ALIAS_MAX_DEPTH = 32, /* directory recursion depth */
};
/* ── Helpers ───────────────────────────────────────────────────── */
/* Strip .ts/.tsx/.js/.jsx in place. Returns its argument. */
static char *strip_resolved_ext(char *path) {
if (!path) {
return path;
}
size_t len = strlen(path);
if (len > 3 && path[len - 3] == '.' && (path[len - 2] == 't' || path[len - 2] == 'j') &&
path[len - 1] == 's') {
path[len - 3] = '\0';
return path;
}
if (len > 4 && path[len - 4] == '.' && (path[len - 3] == 't' || path[len - 3] == 'j') &&
path[len - 2] == 's' && path[len - 1] == 'x') {
path[len - 4] = '\0';
}
return path;
}
/* Join dir_prefix with target, collapsing "." and ".." segments so aliases
* that climb out of their tsconfig's directory (the common monorepo
* pattern: a tsconfig at apps/web/tsconfig.json pointing an alias at a
* wildcard target like "../../packages/shared/src/" + wildcard) resolve
* to a real repo-relative path. Naive concatenation left literal ".."
* components in the target, which never match a module's FQN since
* cbm_pipeline_fqn_module tokenizes on '/' without collapsing them
* (#730). A trailing '/' on target (the usual case right before a
* wildcard) is preserved so the caller's later wildcard-substring
* concat still lines up. Returns heap-allocated
* repo-relative target. */
static char *resolve_target_relative(const char *dir_prefix, const char *target) {
if (!target) {
return NULL;
}
size_t dp_len = (dir_prefix && dir_prefix[0] != '\0') ? strlen(dir_prefix) : 0;
size_t t_len = strlen(target);
char *buf = malloc(dp_len + t_len + 2);
if (!buf) {
return NULL;
}
buf[0] = '\0';
if (dp_len > 0) {
memcpy(buf, dir_prefix, dp_len);
buf[dp_len] = '\0';
}
bool trailing_slash = t_len > 0 && target[t_len - 1] == '/';
const char *p = target;
while (*p) {
while (*p == '/') {
p++;
}
if (!*p) {
break;
}
const char *seg_start = p;
while (*p && *p != '/') {
p++;
}
size_t seg_len = (size_t)(p - seg_start);
if (seg_len == 1 && seg_start[0] == '.') {
continue;
}
if (seg_len == 2 && seg_start[0] == '.' && seg_start[1] == '.') {
char *last = strrchr(buf, '/');
if (last) {
*last = '\0';
} else {
buf[0] = '\0';
}
continue;
}
size_t cur = strlen(buf);
if (cur > 0) {
buf[cur++] = '/';
}
memcpy(buf + cur, seg_start, seg_len);
buf[cur + seg_len] = '\0';
}
if (trailing_slash) {
size_t cur = strlen(buf);
buf[cur] = '/';
buf[cur + 1] = '\0';
}
return buf;
}
/* qsort comparator: alias entries by alias_prefix length, descending. */
static int cmp_alias_entry_by_specificity(const void *a, const void *b) {
const cbm_path_alias_t *ea = a;
const cbm_path_alias_t *eb = b;
size_t la = strlen(ea->alias_prefix);
size_t lb = strlen(eb->alias_prefix);
if (lb > la) {
return 1;
}
if (lb < la) {
return -1;
}
return 0;
}
/* qsort comparator: scopes by dir_prefix length, descending. */
static int cmp_scope_by_specificity(const void *a, const void *b) {
const cbm_path_alias_scope_t *sa = a;
const cbm_path_alias_scope_t *sb = b;
size_t la = strlen(sa->dir_prefix);
size_t lb = strlen(sb->dir_prefix);
if (lb > la) {
return 1;
}
if (lb < la) {
return -1;
}
return 0;
}
/* ── tsconfig.json / jsconfig.json loader ──────────────────────── */
/* Parse compilerOptions.paths and compilerOptions.baseUrl into an alias map.
* dir_prefix is the directory of the config file relative to the repo root
* (e.g. "apps/manager", or "" for repo root). Returns NULL if the file is
* missing, malformed, or has neither a usable paths block nor a baseUrl. */
static cbm_path_alias_map_t *load_tsconfig_file(const char *abs_path, const char *dir_prefix) {
FILE *f = cbm_fopen(abs_path, "r");
if (!f) {
return NULL;
}
fseek(f, 0, SEEK_END);
long len = ftell(f);
fseek(f, 0, SEEK_SET);
if (len <= 0 || len > CBM_PATH_ALIAS_MAX_FILE_BYTES) {
fclose(f);
return NULL;
}
char *buf = malloc((size_t)len + 1);
if (!buf) {
fclose(f);
return NULL;
}
size_t nread = fread(buf, 1, (size_t)len, f);
fclose(f);
buf[nread] = '\0';
yyjson_read_flag flg = YYJSON_READ_ALLOW_COMMENTS | YYJSON_READ_ALLOW_TRAILING_COMMAS;
yyjson_doc *doc = yyjson_read(buf, nread, flg);
free(buf);
if (!doc) {
return NULL;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *compiler_opts = yyjson_obj_get(root, "compilerOptions");
if (!compiler_opts) {
yyjson_doc_free(doc);
return NULL;
}
yyjson_val *base_url_val = yyjson_obj_get(compiler_opts, "baseUrl");
const char *base_url_str = base_url_val ? yyjson_get_str(base_url_val) : NULL;
yyjson_val *paths_obj = yyjson_obj_get(compiler_opts, "paths");
if (!paths_obj && !base_url_str) {
yyjson_doc_free(doc);
return NULL;
}
cbm_path_alias_map_t *map = calloc(1, sizeof(*map));
if (!map) {
yyjson_doc_free(doc);
return NULL;
}
if (base_url_str && base_url_str[0] != '\0' && strcmp(base_url_str, ".") != 0) {
map->base_url = resolve_target_relative(dir_prefix, base_url_str);
} else if (base_url_str && strcmp(base_url_str, ".") == 0 && dir_prefix &&
dir_prefix[0] != '\0') {
map->base_url = strdup(dir_prefix);
}
if (paths_obj && yyjson_is_obj(paths_obj)) {
size_t obj_size = yyjson_obj_size(paths_obj);
bool capped = obj_size > CBM_PATH_ALIAS_MAX_ENTRIES;
int capacity = (int)(capped ? (size_t)CBM_PATH_ALIAS_MAX_ENTRIES : obj_size);
if (capacity > 0) {
map->entries = calloc((size_t)capacity, sizeof(cbm_path_alias_t));
if (!map->entries) {
free(map->base_url);
free(map);
yyjson_doc_free(doc);
return NULL;
}
yyjson_val *key;
yyjson_obj_iter iter = yyjson_obj_iter_with(paths_obj);
while ((key = yyjson_obj_iter_next(&iter)) != NULL && map->count < capacity) {
yyjson_val *val = yyjson_obj_iter_get_val(key);
const char *alias_pattern = yyjson_get_str(key);
if (!alias_pattern || !yyjson_is_arr(val) || yyjson_arr_size(val) == 0) {
continue;
}
const char *target_pattern = yyjson_get_str(yyjson_arr_get_first(val));
if (!target_pattern) {
continue;
}
cbm_path_alias_t *entry = &map->entries[map->count];
const char *star = strchr(alias_pattern, '*');
if (star) {
entry->has_wildcard = true;
entry->alias_prefix =
cbm_strndup(alias_pattern, (size_t)(star - alias_pattern));
entry->alias_suffix = strdup(star + 1);
} else {
entry->has_wildcard = false;
entry->alias_prefix = strdup(alias_pattern);
entry->alias_suffix = strdup("");
}
const char *tstar = strchr(target_pattern, '*');
if (tstar) {
char *pre = cbm_strndup(target_pattern, (size_t)(tstar - target_pattern));
entry->target_prefix = resolve_target_relative(dir_prefix, pre);
free(pre);
entry->target_suffix = strdup(tstar + 1);
} else {
entry->target_prefix = resolve_target_relative(dir_prefix, target_pattern);
entry->target_suffix = strdup("");
}
map->count++;
}
if (capped) {
cbm_log_warn("path_alias.entries.cap_hit", "config", abs_path, "kept",
/* itoa via thread-local buffer would be tidier; keep simple */
"256_of_more");
}
qsort(map->entries, (size_t)map->count, sizeof(cbm_path_alias_t),
cmp_alias_entry_by_specificity);
}
}
yyjson_doc_free(doc);
return map;
}
/* ── Public API ────────────────────────────────────────────────── */
void cbm_path_alias_collection_free(cbm_path_alias_collection_t *coll) {
if (!coll) {
return;
}
for (int i = 0; i < coll->count; i++) {
free(coll->scopes[i].dir_prefix);
if (coll->scopes[i].map) {
cbm_path_alias_map_t *map = coll->scopes[i].map;
for (int j = 0; j < map->count; j++) {
free(map->entries[j].alias_prefix);
free(map->entries[j].alias_suffix);
free(map->entries[j].target_prefix);
free(map->entries[j].target_suffix);
}
free(map->entries);
free(map->base_url);
free(map);
}
}
free(coll->scopes);
free(coll);
}
char *cbm_path_alias_resolve(const cbm_path_alias_map_t *map, const char *module_path) {
if (!map || !module_path) {
return NULL;
}
size_t mod_len = strlen(module_path);
for (int i = 0; i < map->count; i++) {
const cbm_path_alias_t *e = &map->entries[i];
if (e->has_wildcard) {
size_t prefix_len = strlen(e->alias_prefix);
size_t suffix_len = strlen(e->alias_suffix);
if (mod_len < prefix_len + suffix_len) {
continue;
}
if (strncmp(module_path, e->alias_prefix, prefix_len) != 0) {
continue;
}
if (suffix_len > 0 &&
strcmp(module_path + mod_len - suffix_len, e->alias_suffix) != 0) {
continue;
}
size_t wild_len = mod_len - prefix_len - suffix_len;
const char *wild_start = module_path + prefix_len;
size_t tp_len = strlen(e->target_prefix);
size_t ts_len = strlen(e->target_suffix);
char *result = malloc(tp_len + wild_len + ts_len + 1);
if (!result) {
return NULL;
}
memcpy(result, e->target_prefix, tp_len);
memcpy(result + tp_len, wild_start, wild_len);
memcpy(result + tp_len + wild_len, e->target_suffix, ts_len);
result[tp_len + wild_len + ts_len] = '\0';
return strip_resolved_ext(result);
}
if (strcmp(module_path, e->alias_prefix) == 0) {
return strip_resolved_ext(strdup(e->target_prefix));
}
}
/* baseUrl fallback. Apply only to non-relative imports that look
* sub-path-ish (contain '/' but don't start with '.' or '@'); skips
* obvious package names like "react" or "lodash". */
if (map->base_url && module_path[0] != '.' && module_path[0] != '@' &&
strchr(module_path, '/') != NULL) {
size_t bu_len = strlen(map->base_url);
size_t need = bu_len + 1 + mod_len + 1;
char *result = malloc(need);
if (!result) {
return NULL;
}
snprintf(result, need, "%s/%s", map->base_url, module_path);
return strip_resolved_ext(result);
}
return NULL;
}
/* ── Repo walk ─────────────────────────────────────────────────── */
typedef struct {
char abs[CBM_SZ_512];
char rel[CBM_SZ_256];
} alias_config_hit_t;
static const char *const TS_CONFIG_NAMES[] = {"tsconfig.json", "jsconfig.json"};
enum { TS_CONFIG_NAMES_COUNT = 2 };
static void find_alias_files(const char *abs_dir, const char *rel_dir, alias_config_hit_t *out,
int *count, int max_count, int depth, char **excluded_dirs,
int excluded_count) {
if (*count >= max_count || depth > CBM_PATH_ALIAS_MAX_DEPTH) {
return;
}
cbm_dir_t *d = cbm_opendir(abs_dir);
if (!d) {
return;
}
/* One config file per directory: prefer tsconfig.json over jsconfig.json. */
for (int i = 0; i < TS_CONFIG_NAMES_COUNT && *count < max_count; i++) {
char check[CBM_SZ_512];
snprintf(check, sizeof(check), "%s/%s", abs_dir, TS_CONFIG_NAMES[i]);
FILE *f = cbm_fopen(check, "r");
if (f) {
fclose(f);
snprintf(out[*count].abs, sizeof(out[*count].abs), "%s", check);
snprintf(out[*count].rel, sizeof(out[*count].rel), "%s", rel_dir);
(*count)++;
break;
}
}
cbm_dirent_t *ent;
while ((ent = cbm_readdir(d)) != NULL && *count < max_count) {
if (!ent->is_dir) {
continue;
}
const char *name = ent->name;
if (name[0] == '.' || strcmp(name, "node_modules") == 0 || strcmp(name, "dist") == 0 ||
strcmp(name, "build") == 0 || strcmp(name, ".next") == 0 ||
strcmp(name, "coverage") == 0 || strcmp(name, "target") == 0 /* Rust */) {
continue;
}
char child_abs[CBM_SZ_512];
char child_rel[CBM_SZ_256];
snprintf(child_abs, sizeof(child_abs), "%s/%s", abs_dir, name);
if (rel_dir[0] == '\0') {
snprintf(child_rel, sizeof(child_rel), "%s", name);
} else {
snprintf(child_rel, sizeof(child_rel), "%s/%s", rel_dir, name);
}
if (cbm_pipeline_relpath_is_excluded(child_rel, excluded_dirs, excluded_count)) {
continue;
}
find_alias_files(child_abs, child_rel, out, count, max_count, depth + 1, excluded_dirs,
excluded_count);
}
cbm_closedir(d);
}
cbm_path_alias_collection_t *cbm_load_path_aliases_excluded(const char *repo_path,
char **excluded_dirs,
int excluded_count) {
if (!repo_path) {
return NULL;
}
alias_config_hit_t *hits = calloc(CBM_PATH_ALIAS_MAX_FILES, sizeof(*hits));
if (!hits) {
return NULL;
}
int count = 0;
find_alias_files(repo_path, "", hits, &count, CBM_PATH_ALIAS_MAX_FILES, 0, excluded_dirs,
excluded_count);
if (count >= CBM_PATH_ALIAS_MAX_FILES) {
cbm_log_warn("path_alias.files.cap_hit", "repo", repo_path, "kept", "256_of_more");
}
if (count == 0) {
free(hits);
return NULL;
}
cbm_path_alias_collection_t *coll = calloc(1, sizeof(*coll));
if (!coll) {
free(hits);
return NULL;
}
coll->scopes = calloc((size_t)count, sizeof(cbm_path_alias_scope_t));
if (!coll->scopes) {
free(coll);
free(hits);
return NULL;
}
for (int i = 0; i < count; i++) {
cbm_path_alias_map_t *map = load_tsconfig_file(hits[i].abs, hits[i].rel);
if (!map) {
continue;
}
coll->scopes[coll->count].dir_prefix = strdup(hits[i].rel);
coll->scopes[coll->count].map = map;
coll->count++;
}
free(hits);
if (coll->count == 0) {
free(coll->scopes);
free(coll);
return NULL;
}
qsort(coll->scopes, (size_t)coll->count, sizeof(cbm_path_alias_scope_t),
cmp_scope_by_specificity);
return coll;
}
cbm_path_alias_collection_t *cbm_load_path_aliases(const char *repo_path) {
return cbm_load_path_aliases_excluded(repo_path, NULL, 0);
}
const cbm_path_alias_map_t *cbm_path_alias_find_for_file(const cbm_path_alias_collection_t *coll,
const char *rel_path) {
if (!coll || !rel_path) {
return NULL;
}
for (int i = 0; i < coll->count; i++) {
const char *prefix = coll->scopes[i].dir_prefix;
size_t plen = strlen(prefix);
if (plen == 0) {
return coll->scopes[i].map;
}
if (strncmp(rel_path, prefix, plen) == 0 &&
(rel_path[plen] == '/' || rel_path[plen] == '\0')) {
return coll->scopes[i].map;
}
}
return NULL;
}

Some files were not shown because too many files have changed in this diff Show More