Skip to content
Merged
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
6 changes: 5 additions & 1 deletion internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -1873,7 +1873,11 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk

if (cbm_kind_in_set(node, spec->call_node_types)) {
char *callee = extract_callee_name(ctx->arena, node, ctx->source, ctx->language);
if (callee && callee[0] && !cbm_is_keyword(callee, ctx->language)) {
// Keyword-filter callees, but keep builtins we mint a node for (len, str,
// ...) so the LSP-resolved builtin call still forms a CALLS edge.
if (callee && callee[0] &&
(!cbm_is_keyword(callee, ctx->language) ||
cbm_is_resolvable_builtin(callee, ctx->language))) {
CBMCall call = {0};
call.callee_name = callee;
call.enclosing_func_qn = state->enclosing_func_qn;
Expand Down
23 changes: 23 additions & 0 deletions internal/cbm/helpers.c
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,29 @@ bool cbm_is_keyword(const char *name, CBMLanguage lang) {
return false;
}

// Builtins that appear in the keyword set above (so they are suppressed as bare
// usages) but for which we mint a real graph node and an LSP resolution, so a
// CALL to them must still be extracted. MUST stay in sync with kPyBuiltinNodes
// in internal/cbm/lsp/py_builtins.c — every entry here has a "builtins.<name>"
// node, so the resulting CALLS edge always has a target (never Module-sourced).
static const char *python_resolvable_builtins[] = {"len", "print", "str", "int",
"list", "dict", "range", NULL};

bool cbm_is_resolvable_builtin(const char *name, CBMLanguage lang) {
if (!name || !name[0]) {
return false;
}
if (lang != CBM_LANG_PYTHON) {
return false;
}
for (const char **b = python_resolvable_builtins; *b; b++) {
if (strcmp(name, *b) == 0) {
return true;
}
}
return false;
}

// --- Export detection ---

bool cbm_is_exported(const char *name, CBMLanguage lang) {
Expand Down
8 changes: 8 additions & 0 deletions internal/cbm/helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ char *cbm_node_text(CBMArena *a, TSNode node, const char *source);
// Check if a string is a language keyword (should be skipped as callee/usage).
bool cbm_is_keyword(const char *name, CBMLanguage lang);

// Check if a name is a builtin we mint a real graph node for, so a CALL to it
// must NOT be keyword-filtered out of call extraction (the LSP resolves it to
// the injected builtin node and forms a CALLS edge). Narrower than
// cbm_is_keyword: it only covers builtins with a target node, so un-filtering
// them cannot produce a node-less / Module-sourced edge. The Python set MUST
// stay in sync with kPyBuiltinNodes in internal/cbm/lsp/py_builtins.c.
bool cbm_is_resolvable_builtin(const char *name, CBMLanguage lang);

// Classify a string literal as URL, config, or neither.
// Returns CBM_STRREF_URL (0), CBM_STRREF_CONFIG (1), or -1 for neither.
int cbm_classify_string(const char *str, int len);
Expand Down
71 changes: 71 additions & 0 deletions internal/cbm/lsp/kotlin_builtins.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* kotlin_builtins.c — Minimal Kotlin universal builtins as real graph nodes.
*
* When a method call lands on an unknown-typed receiver and the member is one
* of the universal kotlin.Any methods (toString / equals / hashCode), the
* Kotlin LSP resolves it to "kotlin.Any.<member>" and emits the lsp_kt_any
* strategy (kotlin_lsp.c, kt_emit_resolved). Any is the supertype of every
* Kotlin reference, so this is the same target the fwcd LSP resolves to.
*
* The missing piece is downstream: pass_calls.c only writes a CALLS edge when
* cbm_pipeline_lsp_target_node() resolves the callee_qn to a graph node
* (src/pipeline/lsp_resolve.h). There is no "kotlin.Any" node in the graph, so
* the resolved call is dropped and the strategy never lands on an edge
* (callable=0).
*
* Fix: inject a small, fixed set of kotlin.Any definitions into result->defs
* during the per-file Kotlin LSP run (cbm_run_kotlin_lsp, which executes inside
* cbm_extract_file, BEFORE the parallel pipeline mints def nodes from
* result->defs). The graph therefore gains real "kotlin.Any[.<method>]" nodes
* that the lsp_kt_any edges target. The QNs here MUST match what kt_emit_resolved
* emits as callee_qn ("kotlin.Any.<member>").
*
* Node minting upserts by QN (cbm_gbuf_upsert_node), so injecting the same
* builtins per Kotlin file collapses to one node per QN — no duplicates.
*
* Self-contained: #included from kotlin_lsp.c only (amalgamation pattern; see
* lsp_all.c). Not a standalone translation unit. Mirror of py_builtins.c.
*/

/* A single builtin entry to mint as a graph node. */
typedef struct {
const char *qn; /* graph QN — MUST equal the kt_emit_resolved callee_qn */
const char *name; /* short name (last segment of qn) */
const char *label; /* "Class" | "Method" */
} KtBuiltinNode;

/*
* Universal kotlin.Any members the LSP falls back to (kt_any_methods in
* kotlin_lsp.c). The Any class node anchors the three methods.
*/
static const KtBuiltinNode kKtBuiltinNodes[] = {
{"kotlin.Any", "Any", "Class"},
{"kotlin.Any.toString", "toString", "Method"},
{"kotlin.Any.equals", "equals", "Method"},
{"kotlin.Any.hashCode", "hashCode", "Method"},
};

/*
* Inject the builtin definitions into result->defs so the pipeline mints them
* as graph nodes. All fields beyond name/qn/label are left zero/NULL: builtins
* have no body, so complexity/line-range/etc. are irrelevant, and a synthetic
* file_path keeps them out of any real source file's def list.
*/
static void kt_builtins_inject_defs(CBMFileResult *result, CBMArena *arena) {
if (!result || !arena) {
return;
}
const int n = (int)(sizeof(kKtBuiltinNodes) / sizeof(kKtBuiltinNodes[0]));
for (int i = 0; i < n; i++) {
const KtBuiltinNode *b = &kKtBuiltinNodes[i];
CBMDefinition def;
memset(&def, 0, sizeof(def));
def.name = b->name;
def.qualified_name = b->qn;
def.label = b->label;
def.file_path = "<kotlin-builtins>";
def.start_line = 1;
def.end_line = 1;
cbm_defs_push(&result->defs, arena, def);
}
}
8 changes: 8 additions & 0 deletions internal/cbm/lsp/kotlin_lsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
#include <stdlib.h>
#include <string.h>

/* Minimal Kotlin universal builtins as real graph nodes (kt_builtins_inject_defs).
* Amalgamation-included (see lsp_all.c); mirror of py_builtins.c. */
#include "kotlin_builtins.c"

#define KT_EVAL_MAX_DEPTH 32
#define KT_IMPORT_INITIAL_CAP 16

Expand Down Expand Up @@ -4117,6 +4121,10 @@ void cbm_run_kotlin_lsp(CBMArena *arena, CBMFileResult *result, const char *sour

kotlin_lsp_process_file(&ctx, use_root);

/* Inject kotlin.Any universal-method nodes (toString/equals/hashCode) so the
* lsp_kt_any fallback emitted above has a target node to form a CALLS edge. */
kt_builtins_inject_defs(result, arena);

if (patched_tree) {
ts_tree_delete(patched_tree);
}
Expand Down
17 changes: 10 additions & 7 deletions tests/repro/repro_issue431.c
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,17 @@ TEST(repro_issue431_vscode_profile_inherits_mcp_json) {
/* --- Precondition: VSCode is detected --- */
cbm_detected_agents_t agents = cbm_detect_agents(tmpdir);
if (!agents.vscode) {
/* Detection failed in the temp tree — adjust path derivation.
* On non-Apple Linux the detection reads cbm_app_config_dir() which
* is process-global (not home-relative), so detection may return false
* for a synthetic tmpdir home. The bug still exists, but we cannot
* demonstrate it via the plan-based oracle without detection firing.
* Mark the test as an expected skip on this platform/config. */
/* #431 IS FIXED: install_vscode_profile_configs() (cli.c:3211) scans
* Code/User/profiles/ and plans a per-profile mcp.json, so the assertion
* below passes as a genuine regression guard whenever detection fires
* (which it does for this fixture). This branch is only reached if
* cbm_detect_agents() cannot see the fixture home on some platform — in
* which case the fix cannot be VERIFIED here. Skip honestly rather than
* vacuously PASS (would hide a future regression) or FAIL (would red a
* fixed bug). */
th_rmtree(tmpdir);
PASS(); /* precondition unmet — non-blocking; bug still open */
SKIP_PLATFORM("VSCode detection did not fire for the synthetic fixture "
"home; cannot verify the #431 per-profile install here");
}

/* --- Run the install plan oracle (dry-run, no mutations) --- */
Expand Down
65 changes: 30 additions & 35 deletions tests/repro/repro_issue581.c
Original file line number Diff line number Diff line change
Expand Up @@ -245,46 +245,41 @@ TEST(repro_issue581_query_rss_stable) {
free(args);
rh_cleanup(&lp, store);

// If RSS is not measurable (cbm_mem_rss() returns 0 and no Linux fallback),
// skip the growth assertion -- an unmeasurable RSS cannot produce a
// meaningful signal. This avoids a false PASS masking a real leak on
// platforms where our RSS API is unavailable.
if (rss_warmup == 0 || rss_end == 0) {
printf(" NOTE: RSS not measurable on this platform/build; "
"growth assertion skipped (inconclusive, not a pass)\n");
PASS();
if (rss_warmup > 0 && rss_end > 0) {
printf(" rss_warmup_kb=%zu rss_end_kb=%zu factor=%.2f threshold=%.1f\n", rss_warmup / 1024,
rss_end / 1024, (double)rss_end / (double)rss_warmup, LEAK_FACTOR);
} else {
printf(" NOTE: RSS not measurable on this platform/build\n");
}

printf(" rss_warmup_kb=%zu rss_end_kb=%zu factor=%.2f threshold=%.1f\n",
rss_warmup / 1024, rss_end / 1024,
(double)rss_end / (double)rss_warmup,
LEAK_FACTOR);

// PRIMARY assertion: end RSS must not exceed LEAK_FACTOR x warmup RSS.
// HONEST RED — this guard is currently VACUOUS and #581 is OPEN.
//
// RED condition (current code):
// SQLite WAL + mimalloc retained pages grow each iteration.
// Over 150 iterations the cumulative growth pushes rss_end above
// LEAK_FACTOR * rss_warmup.
// ASSERT fires -> RED.
// This fixture CANNOT reproduce the leak: a 3-node graph over 150
// search_graph calls allocates far too little to move process RSS (observed
// factor=1.00), so the old "rss_end <= 3.0 x rss_warmup" assertion passed
// even on the leaking build. A green here would mean "leak fixed" while the
// leak is unfixed — a false guard that violates the tests-are-guards rule
// (green <=> fixed). So it stays RED.
//
// GREEN condition (after fix):
// Periodic compaction (cbm_mem_collect + WAL TRUNCATE checkpoint) keeps
// rss_end near rss_warmup. factor stays <1.5 comfortably.
// Turning this GREEN legitimately requires BOTH:
// (a) a real reproduction tier — a long-running MCP session issuing
// thousands of ops against a LARGE graph, measuring the SQLite WAL
// file size and mimalloc committed pages DIRECTLY (not process-RSS
// jitter) so the monotonic growth is actually observable; AND
// (b) the fix — periodic SQLITE_CHECKPOINT_TRUNCATE + cbm_mem_collect() in
// the MCP query loop / idle eviction (see the header + #581).
//
// We report the ratio in the failure message so the fixer can see the
// growth slope without needing a profiler.
size_t rss_limit = (size_t)(rss_warmup * LEAK_FACTOR);
if (rss_end > rss_limit) {
printf(" BUG #581 reproduced: RSS grew %.2fx after %d search_graph calls "
"(warmup=%zu kB end=%zu kB limit=%zu kB)\n",
(double)rss_end / (double)rss_warmup,
ITER_TOTAL - ITER_WARMUP,
rss_warmup / 1024, rss_end / 1024, rss_limit / 1024);
}
ASSERT(rss_end <= rss_limit);

PASS();
// Until both land this is an honest "not fixed / not provable here" RED, not
// a false green.
/* TODO(#581): whitelisted known-red on the non-gating bug-repro board. The
* leak is a real OPEN bug; this fixture cannot yet reproduce it, so the test
* stays RED (honest "not fixed") rather than vacuously green. Turning it
* green requires a real WAL-size / mimalloc-committed-pages reproduction tier
* plus the query-path compaction fix (see header). Tracked, not skipped. */
FAIL("TODO(#581) whitelisted known-red: query-path memory leak is OPEN and "
"cannot be reproduced in this fixture (RSS factor ~1.0 even when "
"leaking) — needs a real WAL/committed-pages reproduction tier plus the "
"query-path compaction fix");
}

// -- Suite ------------------------------------------------------------------
Expand Down
35 changes: 25 additions & 10 deletions tests/repro/repro_lsp_c_cpp.c
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ static int assert_no_resolvable_edge(const char *filename, const char *src,
return 1;
}
int rc = 0;
/* Exercised-check: the fixture MUST produce at least one callable-sourced
* CALLS edge (its in-fixture control call). Without it the "no edge to
* <callee>" invariant is VACUOUS — it also passes when extraction silently
* produced nothing, so a green would not prove the unresolvable call was
* actually processed and correctly dropped. */
int module_sourced = -1;
int callable_sourced = -1;
inv_count_calls_by_source(store, lp.project, &module_sourced, &callable_sourced);
(void)module_sourced;
if (callable_sourced <= 0) {
printf(" %sFAIL%s %s:%d: no callable-sourced CALLS edge — fixture not "
"exercised; the no-edge invariant for %s is vacuous\n",
tf_red(), tf_reset(), __FILE__, __LINE__, callee_substr);
rc = 1;
}
if (!inv_no_calls_edge_to_qn(store, lp.project, callee_substr)) {
printf(" %sFAIL%s %s:%d: a CALLS edge unexpectedly targets %s "
"(expected NONE — callee is unresolvable)\n",
Expand Down Expand Up @@ -302,12 +317,12 @@ static const char kFuncPtr[] =
* (RED) — it documents that the DLL-resolution path needs an external binding
* the single-file harness can't synthesize. The fixture below at least exercises
* a pointer assigned from an extern declaration. */
static const char kDllResolve[] =
"extern int plugin_entry(int x);\n"
"int caller(int v) {\n"
" int (*fp)(int) = plugin_entry;\n"
" return fp(v);\n"
"}\n";
static const char kDllResolve[] = "extern int plugin_entry(int x);\n"
"int known(int x) { return x + 1; }\n"
"int caller(int v) {\n"
" int (*fp)(int) = plugin_entry;\n"
" return known(v) + fp(v);\n"
"}\n";

/* lsp_operator — overloaded binary operator+ on a custom type (c_lsp.c:3771-3789:
* binary_expression, lhs is a custom type, operator+ member found). */
Expand Down Expand Up @@ -387,10 +402,10 @@ static const char kAdl[] =
* called with a NULL callee_qn; the more common unresolved path is
* c_emit_unresolved_call (a different marker). This fixture exercises a call to
* an undeclared function and documents whether "lsp_unresolved" surfaces. */
static const char kUnresolved[] =
"int caller(int v) {\n"
" return totally_unknown_fn(v);\n"
"}\n";
static const char kUnresolved[] = "int known(int x) { return x + 1; }\n"
"int caller(int v) {\n"
" return known(v) + totally_unknown_fn(v);\n"
"}\n";

/* ── Per-strategy tests ──────────────────────────────────────────────────── */

Expand Down
56 changes: 31 additions & 25 deletions tests/repro/repro_lsp_go_py.c
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,21 @@ static int assert_no_resolvable_edge_files(const RFile *files, int nfiles,
return 1;
}
int rc = 0;
/* Exercised-check: the fixture MUST produce at least one callable-sourced
* CALLS edge (its in-fixture control call). Without it the "no edge to
* <callee>" invariant is VACUOUS — it also passes when extraction silently
* produced nothing, so a green would not prove the unresolvable call was
* actually processed and correctly dropped. */
int module_sourced = -1;
int callable_sourced = -1;
inv_count_calls_by_source(store, lp.project, &module_sourced, &callable_sourced);
(void)module_sourced;
if (callable_sourced <= 0) {
printf(" %sFAIL%s %s:%d: no callable-sourced CALLS edge — fixture not "
"exercised; the no-edge invariant for %s is vacuous\n",
tf_red(), tf_reset(), __FILE__, __LINE__, callee_substr);
rc = 1;
}
if (!inv_no_calls_edge_to_qn(store, lp.project, callee_substr)) {
printf(" %sFAIL%s %s:%d: a CALLS edge unexpectedly targets %s "
"(expected NONE — callee is unresolvable)\n",
Expand Down Expand Up @@ -309,11 +324,11 @@ static const RFile kGoCrossFile[] = {
* "lsp_unresolved"). NOTE: emit_unresolved_call uses confidence 0.0, so the
* pipeline may not promote it into a CALLS edge with the strategy tag — this
* fixture documents whether "lsp_unresolved" surfaces in the graph. */
static const char kGoUnresolved[] =
"package main\n"
"func caller(v int) int {\n"
" return totallyUnknownFn(v)\n"
"}\n";
static const char kGoUnresolved[] = "package main\n"
"func known(x int) int { return x + 1 }\n"
"func caller(v int) int {\n"
" return known(v) + totallyUnknownFn(v)\n"
"}\n";

/* ── Python fixtures ───────────────────────────────────────────────────────── */

Expand Down Expand Up @@ -386,13 +401,13 @@ static const RFile kPyModuleAttr[] = {
* symbol lookup misses → best-effort "module.attr" QN, low confidence). helpers
* defines nothing named missing_fn. */
static const RFile kPyModuleAttrUnresolved[] = {
{"helpers.py",
"def do_work(x):\n"
" return x + 9\n"},
{"main.py",
"import helpers\n"
"def caller(v):\n"
" return helpers.missing_fn(v)\n"},
{"helpers.py", "def do_work(x):\n"
" return x + 9\n"},
{"main.py", "import helpers\n"
"def known(x):\n"
" return x + 1\n"
"def caller(v):\n"
" return known(v) + helpers.missing_fn(v)\n"},
};

/* lsp_dict_dispatch — funcs["key"]() where funcs is a dict-literal dispatch
Expand Down Expand Up @@ -571,23 +586,14 @@ TEST(repro_lsp_py_operator_dunder) {
}

TEST(repro_lsp_py_builtin) {
/* PARKED for release: lsp_builtin (len(v)) needs a typeshed/builtins registry
* so builtin functions have target nodes; without it the resolution has no
* node to form a CALLS edge to (callable=0). Tracked for a future builtins
* registry. */
printf(" %sSKIP%s parked: needs builtins/typeshed registry (len has no node)\n", tf_dim(),
tf_reset());
return -1; /* skip — not counted as pass or fail */
/* len(v) resolves to the injected builtins.len node (py_builtins.c) and
* emits lsp_builtin with a real CALLS edge. */
return assert_lsp_strategy("main.py", kPyBuiltin, "lsp_builtin");
}

TEST(repro_lsp_py_builtin_constructor) {
/* PARKED for release: lsp_builtin_constructor (str(v)) needs a builtins/
* typeshed registry so the builtin type str has a node to target. Tracked
* for a future builtins registry. */
printf(" %sSKIP%s parked: needs builtins/typeshed registry (str type has no node)\n", tf_dim(),
tf_reset());
return -1; /* skip — not counted as pass or fail */
/* str(v) resolves to the injected builtins.str type node (py_builtins.c)
* and emits lsp_builtin_constructor with a real CALLS edge. */
return assert_lsp_strategy("main.py", kPyBuiltinConstructor,
"lsp_builtin_constructor");
}
Expand Down
Loading
Loading