chore: import upstream snapshot with attribution
This commit is contained in:
+4949
File diff suppressed because it is too large
Load Diff
+348
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 */
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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 0x00–0xFF */
|
||||
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 */
|
||||
@@ -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, ¤t_rss, &peak_rss, ¤t_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);
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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, ¤t_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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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"
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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", "a, &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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+165
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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. */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(¤t, 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(¤t, 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(¤t);
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user