mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-21 05:59:35 +08:00
The test-impact engine selects tests by walking the graph, so it can only be as right as the graph. Three gaps made it wrong: One identity for a definition's mutually exclusive variants - A definition written once per #if/#else branch, per #[cfg], per platform file (foo_linux.c / foo_win.c, platform directories) or per Go build tag is ONE node. The graph buffer keeps every variant's span in a `variants` node property (definition labels only), so a change in any variant maps to the definition. - Extraction now sees definitions inside conditional blocks where it skipped them: C++/C#/ObjC class members under #if (one cursor walk, conditional block types per language in lang_specs), Elixir defs under if/unless, Fortran .F90/.F95/.F03/.F08, Rust #[cfg] twins (no more `name#cfg(...)` QNs that lost every call), C platform files and directories (non-static file-scope functions). - Haskell: the vendored scanner reads only an #if group's first branch. The grammar stays upstream; every further branch is parsed as its own same-length view (cpp_branch_views.c) and adds what the raw parse missed. haskell/bytestring: no new partial parses, +27 functions, IMPORTS 322 -> 329, 131 definitions with variant spans. - Incremental closure repair re-parses every file of such a definition when one of them changes (variant partners through DEFINES), so a repair equals a full index. No cross-language name guesses - A name-only resolution (unique_name, suffix_match, qualified_suffix, field_type_hint, and same_module across files that only share a path stem) never binds a symbol another language defines. - Bash imports are the files a script sources: every top-level command was read as an import of its name (`set -e` imported "set", bound to a TSX method in 137 scripts here). - This repository: cross-language CALLS 5,916 -> 5,091; the remainder are C/C++ pairs, builtins and same-stem module collisions (recorded). SPAWNS edges (per language, like the HTTP client patterns) - A call that starts a program (subprocess.run, exec.Command, child_process.spawn, posix_spawn, system, Process.Start, Command::new, ... about 30 languages, through the file's own imports) gets a SPAWNS edge to a Process node named after the program (literal first word, or <dynamic>) instead of a CALLS guess. No target resolution. Both resolvers ask at the same point (parity test). The engine follows the graph - Seeds map changed lines through every variant span; a test written once per #if branch is one test; when a change reaches a non-test `main`, the tests that spawn processes are reached too (the process runs main, and main reaches everything). Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
377 lines
16 KiB
C
377 lines
16 KiB
C
/*
|
|
* test_spawns.c — SPAWNS edges: a call that starts another program, to the
|
|
* Process node of that program (internal/cbm/spawn_patterns.h,
|
|
* src/pipeline/pass_spawns.c).
|
|
*
|
|
* Unit tests pin how a call is recognised (spellings, the file's imports,
|
|
* bare names only when nothing in the project answers) and which program it
|
|
* starts (a literal, a collection's first element, never an argument after a
|
|
* variable program). Pipeline tests pin the edges in an indexed repo and that
|
|
* the sequential and parallel resolvers emit the same ones.
|
|
*/
|
|
#include "../src/foundation/compat.h"
|
|
#include "test_framework.h"
|
|
#include "test_helpers.h"
|
|
#include <foundation/compat_fs.h>
|
|
#include <mcp/mcp.h>
|
|
#include <pipeline/pipeline.h>
|
|
#include <store/store.h>
|
|
#include <sqlite3.h>
|
|
#include "spawn_patterns.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <unistd.h>
|
|
|
|
/* ── Recognition ─────────────────────────────────────────────────── */
|
|
|
|
static const char *sp_api(CBMLanguage lang, const char *callee, const CBMImport *imports,
|
|
int count) {
|
|
const cbm_spawn_api_t *api = cbm_spawn_match(lang, callee, imports, count);
|
|
return api ? api->api : NULL;
|
|
}
|
|
|
|
TEST(spawn_match_through_python_imports) {
|
|
const CBMImport imports[] = {{.local_name = "sp", .module_path = "subprocess"},
|
|
{.local_name = "run", .module_path = "subprocess.run"},
|
|
{.local_name = "os", .module_path = "os"}};
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "sp.check_output", imports, 3),
|
|
"subprocess.check_output");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "run", imports, 3), "subprocess.run");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "subprocess.Popen", NULL, 0), "subprocess.Popen");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_PYTHON, "os.spawnv", imports, 3), "os.spawnv");
|
|
/* A bare `run` is not subprocess.run without the import that says so. */
|
|
ASSERT_NULL(sp_api(CBM_LANG_PYTHON, "run", NULL, 0));
|
|
ASSERT_NULL(sp_api(CBM_LANG_PYTHON, "system", NULL, 0));
|
|
PASS();
|
|
}
|
|
|
|
TEST(spawn_match_through_go_rust_js_imports) {
|
|
const CBMImport go[] = {{.local_name = "exec", .module_path = "os/exec"}};
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_GO, "exec.Command", go, 1), "os/exec.Command");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_GO, "exec.CommandContext", go, 1), "os/exec.CommandContext");
|
|
ASSERT_NULL(sp_api(CBM_LANG_GO, "exec.Command", NULL, 0));
|
|
|
|
const CBMImport rust[] = {{.local_name = "Command", .module_path = "std::process::Command"}};
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_RUST, "Command::new", rust, 1), "std::process::Command::new");
|
|
ASSERT_NULL(sp_api(CBM_LANG_RUST, "Command::new", NULL, 0));
|
|
|
|
/* `import { spawn } from "node:child_process"`, `const cp = require(..)`,
|
|
* and a destructured require that records only the module. */
|
|
const CBMImport named[] = {{.local_name = "spawn", .module_path = "node:child_process"},
|
|
{.local_name = "cp", .module_path = "child_process"}};
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_TYPESCRIPT, "spawn", named, 2), "child_process.spawn");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_JAVASCRIPT, "cp.execSync", named, 2), "child_process.execSync");
|
|
const CBMImport destructured[] = {
|
|
{.local_name = "child_process", .module_path = "child_process"}};
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_JAVASCRIPT, "execFile", destructured, 1), "execFile");
|
|
ASSERT_NULL(sp_api(CBM_LANG_JAVASCRIPT, "execFile", NULL, 0));
|
|
PASS();
|
|
}
|
|
|
|
/* Bare names: libc-style ones by spelling (a definition of the name in the
|
|
* calling module still wins, see spawns_in_the_graph), import-gated ones only
|
|
* with their import. */
|
|
TEST(spawn_match_bare_names) {
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_C, "system", NULL, 0), "system");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_CPP, "std::system", NULL, 0), "std::system");
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_RUBY, "spawn", NULL, 0), "spawn");
|
|
const CBMImport hs[] = {{.local_name = "Process", .module_path = "System.Process"}};
|
|
ASSERT_STR_EQ(sp_api(CBM_LANG_HASKELL, "callProcess", hs, 1), "callProcess");
|
|
ASSERT_NULL(sp_api(CBM_LANG_HASKELL, "callProcess", NULL, 0));
|
|
ASSERT_NULL(sp_api(CBM_LANG_GO, "system", NULL, 0));
|
|
ASSERT_NULL(sp_api(CBM_LANG_C, "systemd_notify", NULL, 0));
|
|
PASS();
|
|
}
|
|
|
|
/* ── The program ─────────────────────────────────────────────────── */
|
|
|
|
/* Program of `callee(arg0, arg1)` as the extractor records it. */
|
|
static const char *sp_program(CBMLanguage lang, const char *callee, const char *arg0,
|
|
const char *arg1, const CBMImport *imports, int count, char *buf) {
|
|
CBMCallArg args[2] = {{.expr = arg0, .index = 0}, {.expr = arg1, .index = 1}};
|
|
CBMCall call = {.callee_name = callee, .args = args, .arg_count = arg1 ? 2 : (arg0 ? 1 : 0)};
|
|
const cbm_spawn_api_t *api = cbm_spawn_match(lang, callee, imports, count);
|
|
if (!api) {
|
|
return "(no match)";
|
|
}
|
|
cbm_spawn_program(api, lang, &call, buf, CBM_SPAWN_PROGRAM_MAX);
|
|
return buf;
|
|
}
|
|
|
|
TEST(spawn_program_rules) {
|
|
char b[CBM_SPAWN_PROGRAM_MAX];
|
|
const CBMLanguage py = CBM_LANG_PYTHON;
|
|
ASSERT_STR_EQ(sp_program(py, "subprocess.run", "[\"git\", \"status\"]", NULL, NULL, 0, b),
|
|
"git");
|
|
/* The program is the variable; the arguments after it are not. */
|
|
ASSERT_STR_EQ(sp_program(py, "subprocess.run", "[binary, \"--version\"]", NULL, NULL, 0, b),
|
|
CBM_SPAWN_DYNAMIC);
|
|
ASSERT_STR_EQ(sp_program(py, "subprocess.Popen", "record[\"argv\"]", NULL, NULL, 0, b),
|
|
CBM_SPAWN_DYNAMIC);
|
|
ASSERT_STR_EQ(sp_program(py, "subprocess.run", "os.path.join(\"bin\", tool)", NULL, NULL, 0, b),
|
|
CBM_SPAWN_DYNAMIC);
|
|
ASSERT_STR_EQ(sp_program(py, "subprocess.run", "f\"{tool} --x\"", NULL, NULL, 0, b),
|
|
CBM_SPAWN_DYNAMIC);
|
|
ASSERT_STR_EQ(sp_program(py, "subprocess.run", "args=[\"make\", \"all\"]", NULL, NULL, 0, b),
|
|
"make");
|
|
ASSERT_STR_EQ(sp_program(py, "os.system", "\"rm -rf build\"", NULL, NULL, 0, b), "rm");
|
|
/* Environment prefixes and directories are not the program. */
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_C, "popen", "\"LC_ALL=C ps -eo pid\"", "\"r\"", NULL, 0, b),
|
|
"ps");
|
|
ASSERT_STR_EQ(
|
|
sp_program(CBM_LANG_C, "system", "\"/usr/bin/env FOO=1 make all\"", NULL, NULL, 0, b),
|
|
"make");
|
|
/* posix_spawnp names the program second; CreateProcess in its command
|
|
* line when the application name is NULL. */
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_C, "posix_spawnp", "&pid", "\"git\"", NULL, 0, b), "git");
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_C, "CreateProcessW", "NULL", "L\"cmd /c dir\"", NULL, 0, b),
|
|
"cmd");
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_C, "execvp", "argv[0]", "argv", NULL, 0, b),
|
|
CBM_SPAWN_DYNAMIC);
|
|
ASSERT_STR_EQ(
|
|
sp_program(CBM_LANG_KOTLIN, "ProcessBuilder", "listOf(\"git\", \"log\")", NULL, NULL, 0, b),
|
|
"git");
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_SWIFT, "Process.launchedProcess", "launchPath: \"/bin/ls\"",
|
|
"arguments: []", NULL, 0, b),
|
|
"ls");
|
|
/* Options tuples / structs: the program is the literal inside. */
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_ELIXIR, "Port.open", "{:spawn, \"cat\"}", "[]", NULL, 0, b),
|
|
"cat");
|
|
/* Groovy: the receiver is the command line. */
|
|
ASSERT_STR_EQ(sp_program(CBM_LANG_GROOVY, "\"git status\".execute", NULL, NULL, NULL, 0, b),
|
|
"git");
|
|
PASS();
|
|
}
|
|
|
|
/* ── In the graph ────────────────────────────────────────────────── */
|
|
|
|
typedef struct {
|
|
const char *name;
|
|
const char *content;
|
|
} sp_file_t;
|
|
|
|
typedef struct {
|
|
char tmpdir[256];
|
|
char dbpath[1024];
|
|
char *project;
|
|
cbm_mcp_server_t *srv;
|
|
cbm_store_t *store;
|
|
} sp_proj_t;
|
|
|
|
static bool sp_write(const char *root, const char *name, const char *content) {
|
|
char path[700];
|
|
snprintf(path, sizeof(path), "%s/%s", root, name);
|
|
char *slash = strrchr(path, '/');
|
|
if (slash && slash > path + strlen(root)) {
|
|
*slash = '\0';
|
|
cbm_mkdir_p(path, 0755);
|
|
*slash = '/';
|
|
}
|
|
FILE *f = cbm_fopen(path, "wb");
|
|
if (!f) {
|
|
return false;
|
|
}
|
|
bool ok = fputs(content, f) >= 0;
|
|
return fclose(f) == 0 && ok;
|
|
}
|
|
|
|
/* Index `files`, plus `pad` trivial Python files (enough of them moves the
|
|
* index onto the parallel resolver, MIN_FILES_FOR_PARALLEL). */
|
|
static bool sp_index(sp_proj_t *p, const sp_file_t *files, int count, int pad) {
|
|
memset(p, 0, sizeof(*p));
|
|
snprintf(p->tmpdir, sizeof(p->tmpdir), "%s", th_mktempdir("cbm-sp"));
|
|
if (!p->tmpdir[0]) {
|
|
return false;
|
|
}
|
|
for (int i = 0; i < count; i++) {
|
|
if (!sp_write(p->tmpdir, files[i].name, files[i].content)) {
|
|
return false;
|
|
}
|
|
}
|
|
for (int i = 0; i < pad; i++) {
|
|
char name[64];
|
|
char body[96];
|
|
snprintf(name, sizeof(name), "pad/p%d.py", i);
|
|
snprintf(body, sizeof(body), "def pad_%d():\n return %d\n", i, i);
|
|
if (!sp_write(p->tmpdir, name, body)) {
|
|
return false;
|
|
}
|
|
}
|
|
p->project = cbm_project_name_from_path(p->tmpdir);
|
|
if (!p->project) {
|
|
return false;
|
|
}
|
|
char cache[700];
|
|
const char *configured = getenv("CBM_CACHE_DIR");
|
|
const char *home = getenv("HOME");
|
|
if (configured && configured[0]) {
|
|
snprintf(cache, sizeof(cache), "%s", configured);
|
|
} else {
|
|
snprintf(cache, sizeof(cache), "%s/.cache/codebase-memory-mcp", home ? home : "/tmp");
|
|
}
|
|
cbm_mkdir_p(cache, 0755);
|
|
snprintf(p->dbpath, sizeof(p->dbpath), "%s/%s.db", cache, p->project);
|
|
unlink(p->dbpath);
|
|
p->srv = cbm_mcp_server_new(NULL);
|
|
if (!p->srv) {
|
|
return false;
|
|
}
|
|
char args[800];
|
|
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", p->tmpdir);
|
|
free(cbm_mcp_handle_tool(p->srv, "index_repository", args));
|
|
p->store = cbm_store_open_path(p->dbpath);
|
|
return p->store != NULL;
|
|
}
|
|
|
|
static void sp_cleanup(sp_proj_t *p) {
|
|
if (p->store) {
|
|
cbm_store_close(p->store);
|
|
}
|
|
if (p->srv) {
|
|
cbm_mcp_server_free(p->srv);
|
|
}
|
|
if (p->dbpath[0]) {
|
|
char side[1100];
|
|
unlink(p->dbpath);
|
|
snprintf(side, sizeof(side), "%s-wal", p->dbpath);
|
|
unlink(side);
|
|
snprintf(side, sizeof(side), "%s-shm", p->dbpath);
|
|
unlink(side);
|
|
}
|
|
free(p->project);
|
|
if (p->tmpdir[0]) {
|
|
th_rmtree(p->tmpdir);
|
|
}
|
|
memset(p, 0, sizeof(*p));
|
|
}
|
|
|
|
static int sp_count(sp_proj_t *p, const char *sql, const char *a, const char *b) {
|
|
sqlite3 *db = cbm_store_get_db(p->store);
|
|
sqlite3_stmt *stmt = NULL;
|
|
if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
|
|
sqlite3_finalize(stmt);
|
|
return -1;
|
|
}
|
|
int n = sqlite3_bind_parameter_count(stmt);
|
|
const char *values[] = {p->project, a, b};
|
|
for (int i = 0; i < n && i < 3; i++) {
|
|
sqlite3_bind_text(stmt, i + 1, values[i], -1, SQLITE_TRANSIENT);
|
|
}
|
|
int count = sqlite3_step(stmt) == SQLITE_ROW ? sqlite3_column_int(stmt, 0) : -1;
|
|
sqlite3_finalize(stmt);
|
|
return count;
|
|
}
|
|
|
|
/* SPAWNS edges from the definition named `caller` to the Process `program`. */
|
|
static int sp_spawns(sp_proj_t *p, const char *caller, const char *program) {
|
|
return sp_count(p,
|
|
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id "
|
|
"JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='SPAWNS' "
|
|
"AND s.name=?2 AND t.name=?3 AND t.label='Process'",
|
|
caller, program);
|
|
}
|
|
|
|
static int sp_calls(sp_proj_t *p, const char *caller, const char *callee) {
|
|
return sp_count(p,
|
|
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id "
|
|
"JOIN nodes t ON t.id=e.target_id WHERE e.project=?1 AND e.type='CALLS' "
|
|
"AND s.name=?2 AND t.name=?3",
|
|
caller, callee);
|
|
}
|
|
|
|
/* Every SPAWNS edge as "caller>program|api" lines, sorted, into `out`. */
|
|
static bool sp_edge_set(sp_proj_t *p, char *out, size_t n) {
|
|
sqlite3 *db = cbm_store_get_db(p->store);
|
|
sqlite3_stmt *stmt = NULL;
|
|
const char *sql = "SELECT s.name || '>' || t.name || '|' || json_extract(e.properties,'$.api') "
|
|
"FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON "
|
|
"t.id=e.target_id WHERE e.project=?1 AND e.type='SPAWNS' ORDER BY 1";
|
|
if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
|
|
sqlite3_finalize(stmt);
|
|
return false;
|
|
}
|
|
sqlite3_bind_text(stmt, 1, p->project, -1, SQLITE_TRANSIENT);
|
|
out[0] = '\0';
|
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
|
const char *row = (const char *)sqlite3_column_text(stmt, 0);
|
|
size_t used = strlen(out);
|
|
snprintf(out + used, n - used, "%s\n", row ? row : "?");
|
|
}
|
|
sqlite3_finalize(stmt);
|
|
return true;
|
|
}
|
|
|
|
static const sp_file_t SP_FILES[] = {
|
|
/* subprocess.run inside the module's own `def run`: the resolver's
|
|
* suffix guess (same_module) must not make it a call to `run`. */
|
|
{"tools.py", "import subprocess\n\n\ndef run(cmd):\n return subprocess.run(cmd)\n\n\n"
|
|
"def build():\n subprocess.check_output([\"make\", \"all\"])\n"},
|
|
{"native.c", "#include <stdlib.h>\nint clean(void) { return system(\"rm -rf build\"); }\n"},
|
|
/* A project-defined `system` answers the bare name: a call, no spawn. */
|
|
{"own.c", "static int system(const char *c) { return c != 0; }\n"
|
|
"int use_own(void) { return system(\"x\"); }\n"},
|
|
{"deploy.go", "package main\n\nimport \"os/exec\"\n\n"
|
|
"func deploy() { exec.Command(\"kubectl\", \"apply\").Run() }\n"},
|
|
{"launch.js", "const { spawn } = require(\"child_process\");\n"
|
|
"function start() { spawn(\"node\", [\"server.js\"]); }\n"},
|
|
};
|
|
#define SP_FILE_COUNT ((int)(sizeof(SP_FILES) / sizeof(SP_FILES[0])))
|
|
|
|
TEST(spawns_in_the_graph) {
|
|
sp_proj_t p;
|
|
ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 0));
|
|
ASSERT_EQ(sp_spawns(&p, "build", "make"), 1);
|
|
ASSERT_EQ(sp_spawns(&p, "run", CBM_SPAWN_DYNAMIC), 1);
|
|
ASSERT_EQ(sp_spawns(&p, "clean", "rm"), 1);
|
|
ASSERT_EQ(sp_spawns(&p, "deploy", "kubectl"), 1);
|
|
ASSERT_EQ(sp_spawns(&p, "start", "node"), 1);
|
|
/* The spawn replaces the CALLS edge a guess would have made. */
|
|
ASSERT_EQ(sp_calls(&p, "build", "run"), 0);
|
|
/* A project-defined system() is code, not a process. */
|
|
ASSERT_EQ(sp_calls(&p, "use_own", "system"), 1);
|
|
ASSERT_EQ(sp_count(&p,
|
|
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id WHERE "
|
|
"e.project=?1 AND e.type='SPAWNS' AND s.name=?2",
|
|
"use_own", NULL),
|
|
0);
|
|
/* One Process node per program, named and qualified by it. */
|
|
ASSERT_EQ(sp_count(&p,
|
|
"SELECT COUNT(*) FROM nodes WHERE label='Process' AND name=?2 AND "
|
|
"qualified_name='__process__make'",
|
|
"make", NULL),
|
|
1);
|
|
sp_cleanup(&p);
|
|
PASS();
|
|
}
|
|
|
|
/* The sequential (few files) and parallel (past MIN_FILES_FOR_PARALLEL)
|
|
* resolvers ask at the same point and emit the same SPAWNS edges. */
|
|
TEST(spawns_parallel_matches_sequential) {
|
|
static char seq[4096];
|
|
static char par[4096];
|
|
sp_proj_t p;
|
|
ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 0));
|
|
ASSERT_TRUE(sp_edge_set(&p, seq, sizeof(seq)));
|
|
sp_cleanup(&p);
|
|
ASSERT_TRUE(sp_index(&p, SP_FILES, SP_FILE_COUNT, 60));
|
|
ASSERT_TRUE(sp_edge_set(&p, par, sizeof(par)));
|
|
sp_cleanup(&p);
|
|
ASSERT_TRUE(seq[0] != '\0');
|
|
if (strcmp(seq, par) != 0) {
|
|
fprintf(stderr, " sequential:\n%s parallel:\n%s", seq, par);
|
|
}
|
|
ASSERT_STR_EQ(seq, par);
|
|
PASS();
|
|
}
|
|
|
|
SUITE(spawns) {
|
|
RUN_TEST(spawn_match_through_python_imports);
|
|
RUN_TEST(spawn_match_through_go_rust_js_imports);
|
|
RUN_TEST(spawn_match_bare_names);
|
|
RUN_TEST(spawn_program_rules);
|
|
RUN_TEST(spawns_in_the_graph);
|
|
RUN_TEST(spawns_parallel_matches_sequential);
|
|
}
|