Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ Thumbs.db
# Local project memory (Claude Code auto-memory)
memory/
reference/
.remember/

CLAUDE.md
docs/superpowers/

# Build artifacts
build/
Expand Down
21 changes: 21 additions & 0 deletions src/cypher/cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ static void lex_string_literal(const char *input, int len, int *pos, char quote,
int start = *pos;
char buf[CBM_SZ_4K];
int blen = 0;
const int max_blen = CBM_SZ_4K - 1;
while (*pos < len && input[*pos] != quote) {
if (blen >= max_blen) { (*pos)++; continue; }
if (input[*pos] == '\\' && *pos + SKIP_ONE < len) {
(*pos)++;
Comment on lines +95 to 99
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In lex_string_literal, the overflow truncation check runs before the escape-handling branch. If the string is already at max length and the next bytes are an escape like \", the code will skip only the backslash, then see " on the next iteration and treat it as the closing quote (even though it was escaped), prematurely terminating the string. To preserve correct lexing, handle escape sequences even when truncating (e.g., move the truncation logic after the escape handling, or when truncating and seeing \\ skip both bytes when possible).

Copilot uses AI. Check for mistakes.
switch (input[*pos]) {
Expand Down Expand Up @@ -469,6 +471,9 @@ static int parse_props(parser_t *p, cbm_prop_filter_t **out, int *count) {
int cap = CYP_INIT_CAP4;
int n = 0;
cbm_prop_filter_t *arr = malloc(cap * sizeof(cbm_prop_filter_t));
if (!arr) {
return CBM_NOT_FOUND;
}

while (!check(p, TOK_RBRACE) && !check(p, TOK_EOF)) {
const cbm_token_t *key = expect(p, TOK_IDENT);
Expand Down Expand Up @@ -569,6 +574,9 @@ static int parse_rel_types(parser_t *p, cbm_rel_pattern_t *out) {
int cap = CYP_INIT_CAP4;
int n = 0;
const char **types = malloc(cap * sizeof(const char *));
if (!types) {
return CBM_NOT_FOUND;
}

const cbm_token_t *t = expect(p, TOK_IDENT);
if (!t) {
Expand Down Expand Up @@ -762,6 +770,12 @@ static cbm_expr_t *parse_in_list(parser_t *p, cbm_condition_t *c) {
int vcap = CYP_INIT_CAP8;
int vn = 0;
const char **vals = malloc(vcap * sizeof(const char *));
if (!vals) {
free((void *)c->variable);
free((void *)c->property);
free((void *)c->op);
return NULL;
}
while (!check(p, TOK_RBRACKET) && !check(p, TOK_EOF)) {
if (vn > 0) {
match(p, TOK_COMMA);
Expand Down Expand Up @@ -1061,8 +1075,15 @@ static const char *parse_value_literal(parser_t *p) {
static cbm_case_expr_t *parse_case_expr(parser_t *p) {
/* CASE already consumed */
cbm_case_expr_t *kase = calloc(CBM_ALLOC_ONE, sizeof(cbm_case_expr_t));
if (!kase) {
return NULL;
}
int bcap = CYP_INIT_CAP4;
kase->branches = malloc(bcap * sizeof(cbm_case_branch_t));
if (!kase->branches) {
free(kase);
return NULL;
}

while (check(p, TOK_WHEN)) {
advance(p);
Expand Down
12 changes: 12 additions & 0 deletions src/foundation/compat_thread.c
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ int cbm_thread_join(cbm_thread_t *t) {
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) {
Expand All @@ -77,6 +85,10 @@ int cbm_thread_join(cbm_thread_t *t) {
return pthread_join(t->handle, NULL);
}

int cbm_thread_detach(cbm_thread_t *t) {
return pthread_detach(t->handle);
}
Comment on lines +88 to +90
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On POSIX, cbm_thread_detach calls pthread_detach(t->handle) but leaves t->handle unchanged. That makes it easy to accidentally cbm_thread_join/cbm_thread_detach again on the same cbm_thread_t and get undefined/erroneous behavior. Consider clearing/poisoning the handle after a successful detach (to match the Win32 path) or tracking a detached state in cbm_thread_t.

Copilot uses AI. Check for mistakes.

#endif

/* ── Mutex ────────────────────────────────────────────────────── */
Expand Down
3 changes: 3 additions & 0 deletions src/foundation/compat_thread.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ int cbm_thread_create(cbm_thread_t *t, size_t stack_size, void *(*fn)(void *), v
/* 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
Expand Down
42 changes: 42 additions & 0 deletions src/foundation/platform.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,48 @@ static inline void *safe_realloc(void *ptr, size_t size) {
return tmp;
}

/* Safe free: frees and NULLs a pointer to prevent double-free / use-after-free.
* Accepts void** so it works with any pointer type via the macro. */
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 string free: frees a const char* and NULLs it.
* Casts away const so callers don't need 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))

Comment on lines +34 to +65
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safe_free/safe_buf_free pass &(ptr) cast to void ** into safe_free_impl/safe_buf_free_impl. Converting a T ** to void ** and then writing through it is undefined behavior in C (the pointer-to-pointer types are not compatible). Prefer a macro-based implementation that frees and assigns NULL directly to the lvalue (or use _Generic to dispatch to type-correct helper functions) so you never write through a mis-typed void **.

Copilot uses AI. Check for mistakes.
/* Safe grow: doubles capacity and reallocs when count reaches cap.
* Usage: safe_grow(arr, count, cap, growth_factor)
* Evaluates to the new arr (NULL on OOM — old memory freed by safe_realloc). */
#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))); \
Comment on lines +66 to +72
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safe_grow updates cap before verifying that safe_realloc succeeded. On OOM, safe_realloc frees the old buffer and returns NULL, so after the macro runs you can end up with (arr)==NULL but an enlarged (cap), preventing future growth attempts and making later code prone to NULL deref. Consider computing a new_cap first, reallocating, and only committing cap=new_cap when the reallocation succeeded (also consider guarding cap==0 and checking for cap*sizeof(*arr) overflow).

Suggested change
/* Safe grow: doubles capacity and reallocs when count reaches cap.
* Usage: safe_grow(arr, count, cap, growth_factor)
* Evaluates to the new arr (NULL on OOMold memory freed by safe_realloc). */
#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))); \
/* Safe grow: grows capacity and reallocs when count reaches cap.
* Usage: safe_grow(arr, count, cap, growth_factor)
* On success, updates arr and cap together. On OOM/overflow, arr becomes
* NULL only if safe_realloc freed the old buffer; cap is left unchanged. */
#define safe_grow(arr, n, cap, factor) do { \
if ((size_t)(n) >= (size_t)(cap)) { \
size_t _safe_old_cap = (size_t)(cap); \
size_t _safe_new_cap = _safe_old_cap ? (_safe_old_cap * (size_t)(factor)) : 1u; \
if (_safe_new_cap <= _safe_old_cap) { \
_safe_new_cap = _safe_old_cap + 1u; \
} \
if (sizeof(*(arr)) != 0 && _safe_new_cap <= (SIZE_MAX / sizeof(*(arr)))) { \
void *_safe_tmp = safe_realloc((arr), _safe_new_cap * sizeof(*(arr))); \
if (_safe_tmp) { \
(arr) = _safe_tmp; \
(cap) = _safe_new_cap; \
} else { \
(arr) = NULL; \
} \
} else { \
(arr) = NULL; \
} \

Copilot uses AI. Check for mistakes.
} \
} while (0)
Comment on lines +34 to +74
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description focuses on adding memory helper functions, but this PR also changes watcher concurrency behavior, Cypher lexing/parsing, SQLite error handling, pipeline edge emission, HTTP server threading, and adds multiple .claude/* workflow files. Consider splitting these into separate PRs (or updating the description) so reviewers can assess each behavioral change independently and reduce merge risk.

Copilot uses AI. Check for mistakes.

/* ── Memory mapping ────────────────────────────────────────────── */

/* Map a file read-only into memory. Returns NULL on error.
Expand Down
3 changes: 3 additions & 0 deletions src/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ int main(int argc, char **argv) {
}

/* Create and start watcher in background thread */
/* Initialize log mutex before any threads are created */
cbm_ui_log_init();

Comment on lines 317 to +320
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cbm_ui_log_append now drops all lines until cbm_ui_log_init() runs, but main installs the log sink and immediately emits logs (server.start) before calling cbm_ui_log_init(). That means early startup logs will never reach the UI ring buffer. Call cbm_ui_log_init() before cbm_log_set_sink(cbm_ui_log_append) / before the first log emission (or restore a thread-safe init-on-first-use pattern).

Copilot uses AI. Check for mistakes.
cbm_store_t *watch_store = cbm_store_open_memory();
g_watcher = cbm_watcher_new(watch_store, watcher_index_fn, NULL);

Expand Down
3 changes: 3 additions & 0 deletions src/pipeline/pass_semantic.c
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ static void resolve_decorator(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_node_t *no
char props[CBM_SZ_256];
snprintf(props, sizeof(props), "{\"decorator\":\"%s\"}", decorator);
cbm_gbuf_insert_edge(ctx->gbuf, node->id, dec->id, "DECORATES", props);
/* Also emit CALLS edge so decorator appears in "find all references" queries */
cbm_gbuf_insert_edge(ctx->gbuf, node->id, dec->id, "CALLS",
"{\"kind\":\"decorator\"}");
(*count)++;
}
}
Expand Down
12 changes: 9 additions & 3 deletions src/store/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -2704,7 +2704,9 @@ int cbm_store_get_schema(cbm_store_t *s, const char *project, cbm_schema_info_t
const char *sql = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1 GROUP BY label "
"ORDER BY COUNT(*) DESC;";
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
return CBM_NOT_FOUND;
}
bind_text(stmt, SKIP_ONE, project);

int cap = ST_INIT_CAP_8;
Expand All @@ -2729,7 +2731,9 @@ int cbm_store_get_schema(cbm_store_t *s, const char *project, cbm_schema_info_t
const char *sql = "SELECT type, COUNT(*) FROM edges WHERE project = ?1 GROUP BY type ORDER "
"BY COUNT(*) DESC;";
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
return CBM_NOT_FOUND;
}
Comment on lines 2731 to +2736
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the second sqlite3_prepare_v2 fails, this function now returns immediately, but at that point out->node_labels may already have been allocated/populated by the first query. That causes a leak unless the caller explicitly frees out on failure (current callers don't check the return value). Suggest adding a single cleanup path (e.g., goto fail) that finalizes statements and calls cbm_store_schema_free(out) (or equivalent) before returning an error.

Copilot uses AI. Check for mistakes.
bind_text(stmt, SKIP_ONE, project);

int cap = ST_INIT_CAP_8;
Expand Down Expand Up @@ -3435,7 +3439,9 @@ static bool pkg_in_list(const char *pkg, char **list, int count) {
static int collect_pkg_names(cbm_store_t *s, const char *sql, const char *project, char **pkgs,
int max_pkgs) {
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
return 0;
}
bind_text(stmt, SKIP_ONE, project);
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && count < max_pkgs) {
Expand Down
14 changes: 9 additions & 5 deletions src/ui/http_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,17 @@ static int g_log_count = 0;
static cbm_mutex_t g_log_mutex;
static atomic_int g_log_mutex_init = 0;

/* Must be called once before any threads are created. */
void cbm_ui_log_init(void) {
if (!atomic_exchange(&g_log_mutex_init, 1)) {
cbm_mutex_init(&g_log_mutex);
}
}

/* Called from a log hook — appends a line to the ring buffer (thread-safe) */
void cbm_ui_log_append(const char *line) {
if (!line)
if (!line || !atomic_load(&g_log_mutex_init))
return;
if (!atomic_load(&g_log_mutex_init)) {
cbm_mutex_init(&g_log_mutex);
atomic_store(&g_log_mutex_init, 1);
}
cbm_mutex_lock(&g_log_mutex);
snprintf(g_log_ring[g_log_head], LOG_LINE_MAX, "%s", line);
g_log_head = (g_log_head + 1) % LOG_RING_SIZE;
Expand Down Expand Up @@ -791,6 +794,7 @@ static void handle_index_start(struct mg_connection *c, struct mg_http_message *
mg_http_reply(c, 500, g_cors_json, "{\"error\":\"thread creation failed\"}");
return;
}
cbm_thread_detach(&tid); /* Don't leak thread handle */

mg_http_reply(c, 202, g_cors_json, "{\"status\":\"indexing\",\"slot\":%d,\"path\":\"%s\"}",
slot, job->root_path);
Expand Down
3 changes: 3 additions & 0 deletions src/ui/http_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ void cbm_http_server_run(cbm_http_server_t *srv);
/* Check if the server started successfully (listener bound). */
bool cbm_http_server_is_running(const cbm_http_server_t *srv);

/* Initialize the log ring buffer mutex. Must be called once before any threads. */
void cbm_ui_log_init(void);

/* Append a log line to the UI ring buffer (called from log hook). */
void cbm_ui_log_append(const char *line);

Expand Down
14 changes: 14 additions & 0 deletions src/watcher/watcher.c
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include "foundation/log.h"
#include "foundation/hash_table.h"
#include "foundation/compat.h"
#include "foundation/compat_thread.h"
#include "foundation/compat_fs.h"
#include "foundation/str_util.h"

Expand Down Expand Up @@ -50,6 +51,7 @@ struct cbm_watcher {
cbm_index_fn index_fn;
void *user_data;
CBMHashTable *projects; /* name → project_state_t* */
cbm_mutex_t projects_lock;
atomic_int stopped;
};

Expand Down Expand Up @@ -236,6 +238,7 @@ cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void *
w->index_fn = index_fn;
w->user_data = user_data;
w->projects = cbm_ht_create(CBM_SZ_32);
cbm_mutex_init(&w->projects_lock);
atomic_init(&w->stopped, 0);
return w;
}
Expand All @@ -244,8 +247,11 @@ void cbm_watcher_free(cbm_watcher_t *w) {
if (!w) {
return;
}
cbm_mutex_lock(&w->projects_lock);
cbm_ht_foreach(w->projects, free_state_entry, NULL);
cbm_ht_free(w->projects);
cbm_mutex_unlock(&w->projects_lock);
cbm_mutex_destroy(&w->projects_lock);
free(w);
}

Expand All @@ -264,6 +270,7 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r
}

/* Remove old entry first (key points to state's project_name) */
cbm_mutex_lock(&w->projects_lock);
project_state_t *old = cbm_ht_get(w->projects, project_name);
if (old) {
cbm_ht_delete(w->projects, project_name);
Expand All @@ -272,17 +279,22 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r

project_state_t *s = state_new(project_name, root_path);
cbm_ht_set(w->projects, s->project_name, s);
cbm_mutex_unlock(&w->projects_lock);
Comment on lines 273 to +282
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cbm_watcher_watch calls state_new(...) under the new lock but doesn’t handle OOM: if state_new returns NULL, cbm_ht_set(w->projects, s->project_name, s) will dereference NULL, and the mutex will remain locked on any early return you add later. Add a NULL check for s and ensure the mutex is always unlocked on all exit paths.

Copilot uses AI. Check for mistakes.
cbm_log_info("watcher.watch", "project", project_name, "path", root_path);
}

void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) {
if (!w || !project_name) {
return;
}
cbm_mutex_lock(&w->projects_lock);
project_state_t *s = cbm_ht_get(w->projects, project_name);
if (s) {
cbm_ht_delete(w->projects, project_name);
state_free(s);
}
cbm_mutex_unlock(&w->projects_lock);
if (s) {
cbm_log_info("watcher.unwatch", "project", project_name);
}
}
Expand Down Expand Up @@ -421,7 +433,9 @@ int cbm_watcher_poll_once(cbm_watcher_t *w) {
.now = now_ns(),
.reindexed = 0,
};
cbm_mutex_lock(&w->projects_lock);
cbm_ht_foreach(w->projects, poll_project, &ctx);
cbm_mutex_unlock(&w->projects_lock);
return ctx.reindexed;
}

Expand Down
27 changes: 27 additions & 0 deletions tests/test_cypher.c
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,32 @@ TEST(cypher_lex_single_quote_string) {
PASS();
}

TEST(cypher_lex_string_overflow) {
/* Build a string literal longer than 4096 bytes to verify we don't
* overflow the stack buffer in lex_string_literal. */
const int big = 5000;
/* query: "AAAA...A" (quotes included) */
char *query = malloc(big + 3); /* quote + big chars + quote + NUL */
ASSERT_NOT_NULL(query);
query[0] = '"';
memset(query + 1, 'A', big);
query[big + 1] = '"';
query[big + 2] = '\0';

cbm_lex_result_t r = {0};
int rc = cbm_lex(query, &r);
ASSERT_EQ(rc, 0);
ASSERT_NULL(r.error);
ASSERT_GTE(r.count, 1);
ASSERT_EQ(r.tokens[0].type, TOK_STRING);
/* The string should be truncated to CBM_SZ_4K - 1 (4095) characters. */
ASSERT_EQ((int)strlen(r.tokens[0].text), 4095);
Comment on lines +99 to +100
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test hardcodes the expected truncated length as 4095, which assumes CBM_SZ_4K == 4096. To keep the test consistent if the buffer size constant ever changes, prefer asserting against (CBM_SZ_4K - 1) (or another exported constant) rather than a literal.

Suggested change
/* The string should be truncated to CBM_SZ_4K - 1 (4095) characters. */
ASSERT_EQ((int)strlen(r.tokens[0].text), 4095);
/* The string should be truncated to CBM_SZ_4K - 1 characters. */
ASSERT_EQ((int)strlen(r.tokens[0].text), (CBM_SZ_4K - 1));

Copilot uses AI. Check for mistakes.

cbm_lex_free(&r);
free(query);
PASS();
}

TEST(cypher_lex_number) {
cbm_lex_result_t r = {0};
int rc = cbm_lex("42 3.14", &r);
Expand Down Expand Up @@ -2064,6 +2090,7 @@ SUITE(cypher) {
RUN_TEST(cypher_lex_relationship);
RUN_TEST(cypher_lex_string_literal);
RUN_TEST(cypher_lex_single_quote_string);
RUN_TEST(cypher_lex_string_overflow);
RUN_TEST(cypher_lex_number);
RUN_TEST(cypher_lex_operators);
RUN_TEST(cypher_lex_keywords_case_insensitive);
Expand Down