Port FastAPI Depends() + DLL resolve edge tracking (PR #66)

- FastAPI Depends(func_ref): scans Python function signatures for
  Depends() patterns and creates CALLS edges to the dependency function.
  Without this, auth/DI functions appear as dead code (in_degree=0).
- DLL resolve: scans C/C++ source for GetProcAddress/dlsym/Resolve
  patterns and creates CALLS edges to synthetic stub nodes, enabling
  call graph tracking across DLL boundaries.
- Extension scoping (#28) was already ported in 6a2b1f5.
- SQL size guard (#62) not needed: our C workers use 8MB stacks.

Tests: 3 new (httplink_laravel_path_filter, pipeline_fastapi_depends_edges,
pipeline_dll_resolve_edges). Total: 2041.

Co-Authored-By: kingchenc <kingchenc@users.noreply.github.com>
Co-Authored-By: mariomeyer <mariomeyer@users.noreply.github.com>
This commit is contained in:
Martin Vogel
2026-03-20 19:19:12 +01:00
co-authored by kingchenc mariomeyer
parent 93d332d1e0
commit f92fd666b5
4 changed files with 479 additions and 0 deletions
+309
View File
@@ -16,6 +16,8 @@
#include "foundation/compat.h" #include "foundation/compat.h"
#include "cbm.h" #include "cbm.h"
#include "foundation/compat_regex.h"
#include <stdbool.h> #include <stdbool.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -289,5 +291,312 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file
cbm_log_info("pass.done", "pass", "calls", "total", itoa_log(total_calls), "resolved", cbm_log_info("pass.done", "pass", "calls", "total", itoa_log(total_calls), "resolved",
itoa_log(resolved), "unresolved", itoa_log(unresolved), "errors", itoa_log(resolved), "unresolved", itoa_log(unresolved), "errors",
itoa_log(errors)); itoa_log(errors));
/* Additional pattern-based edge passes run after normal call resolution */
cbm_pipeline_pass_fastapi_depends(ctx, files, file_count);
cbm_pipeline_pass_dll_resolve(ctx, files, file_count);
return 0; 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). */
// NOLINTNEXTLINE(misc-include-cleaner) — cbm_file_info_t provided by standard header
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;
}
/* Check if file has Depends call in cached extraction */
CBMFileResult *result = ctx->result_cache ? ctx->result_cache[i] : NULL;
if (!result) {
continue;
}
bool has_depends = false;
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) {
has_depends = true;
break;
}
}
if (!has_depends) {
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(ctx->project_name, files[i].rel_path);
/* 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 (!def->qualified_name || def->start_line == 0) {
continue;
}
if (strcmp(def->label, "Function") != 0 && strcmp(def->label, "Method") != 0) {
continue;
}
/* Extract function signature (def line through ~15 lines for multi-line sigs) */
int sig_end_line = (int)def->start_line + 15;
if (def->end_line > 0 && sig_end_line > (int)def->end_line) {
sig_end_line = (int)def->end_line;
}
/* Find signature region in source */
const char *p = source;
int line = 1;
while (*p && line < def->start_line) {
if (*p == '\n') {
line++;
}
p++;
}
const char *sig_start = p;
while (*p && line < sig_end_line) {
if (*p == '\n') {
line++;
}
p++;
/* Stop at closing paren + colon (end of Python signature) */
if (p > sig_start + 1 && p[-1] == ':' && p[-2] == ')') {
break;
}
}
size_t sig_len = (size_t)(p - sig_start);
char *sig = malloc(sig_len + 1);
if (!sig) {
continue;
}
memcpy(sig, sig_start, sig_len);
sig[sig_len] = '\0';
/* Match Depends(func_ref) patterns */
cbm_regmatch_t match[2];
const char *scan = sig;
while (cbm_regexec(&depends_re, scan, 2, match, 0) == 0) {
int ref_len = match[1].rm_eo - match[1].rm_so;
char func_ref[256];
if (ref_len >= (int)sizeof(func_ref)) {
ref_len = (int)sizeof(func_ref) - 1;
}
memcpy(func_ref, scan + match[1].rm_so, (size_t)ref_len);
func_ref[ref_len] = '\0';
/* Resolve through registry */
cbm_resolution_t res = cbm_registry_resolve(ctx->registry, func_ref, module_qn,
imp_keys, imp_vals, imp_count);
if (res.qualified_name && res.qualified_name[0] != '\0') {
const cbm_gbuf_node_t *src_node =
cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name);
const cbm_gbuf_node_t *tgt_node =
cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name);
if (src_node && tgt_node && src_node->id != tgt_node->id) {
cbm_gbuf_insert_edge(ctx->gbuf, src_node->id, tgt_node->id, "CALLS",
"{\"confidence\":0.95,\"strategy\":\"fastapi_depends\""
"}");
edge_count++;
}
}
scan += match[0].rm_eo;
}
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 ────────────────────────────────────────── */
/* Scans C/C++ function source for dynamic DLL resolution patterns
* (GetProcAddress, dlsym, Resolve) and creates CALLS edges to synthetic
* stub nodes, enabling call graph tracking across DLL boundaries. */
// NOLINTNEXTLINE(misc-include-cleaner) — cbm_file_info_t provided by standard header
void cbm_pipeline_pass_dll_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count) {
cbm_regex_t getproc_re;
cbm_regex_t dlsym_re;
cbm_regex_t resolve_re;
if (cbm_regcomp(&getproc_re,
"GetProcAddress[AW]?\\([^,]+,[[:space:]]*\"([A-Za-z_][A-Za-z0-9_]*)\"",
CBM_REG_EXTENDED) != 0) {
return;
}
if (cbm_regcomp(&dlsym_re, "dlsym\\([^,]+,[[:space:]]*\"([A-Za-z_][A-Za-z0-9_]*)\"",
CBM_REG_EXTENDED) != 0) {
cbm_regfree(&getproc_re);
return;
}
if (cbm_regcomp(&resolve_re, "[.>]Resolve\\([[:space:]]*\"([A-Za-z_][A-Za-z0-9_]*)\"",
CBM_REG_EXTENDED) != 0) {
cbm_regfree(&getproc_re);
cbm_regfree(&dlsym_re);
return;
}
cbm_regex_t *patterns[] = {&getproc_re, &dlsym_re, &resolve_re};
int edge_count = 0;
for (int i = 0; i < file_count; i++) {
if (files[i].language != CBM_LANG_C && files[i].language != CBM_LANG_CPP) {
continue;
}
if (cbm_pipeline_check_cancel(ctx)) {
break;
}
CBMFileResult *result = ctx->result_cache ? ctx->result_cache[i] : NULL;
if (!result) {
continue;
}
/* Early bail: check if any call targets a DLL resolution function */
bool has_dll_call = false;
for (int c = 0; c < result->calls.count; c++) {
const char *name = result->calls.items[c].callee_name;
if (!name) {
continue;
}
if (strcmp(name, "GetProcAddress") == 0 || strcmp(name, "GetProcAddressA") == 0 ||
strcmp(name, "GetProcAddressW") == 0 || strcmp(name, "dlsym") == 0 ||
strstr(name, "Resolve") != NULL) {
has_dll_call = true;
break;
}
}
if (!has_dll_call) {
continue;
}
int source_len = 0;
char *source = read_file(files[i].path, &source_len);
if (!source) {
continue;
}
char *module_qn = cbm_pipeline_fqn_module(ctx->project_name, files[i].rel_path);
for (int d = 0; d < result->defs.count; d++) {
CBMDefinition *def = &result->defs.items[d];
if (!def->qualified_name || def->start_line == 0 || def->end_line == 0) {
continue;
}
if (strcmp(def->label, "Function") != 0 && strcmp(def->label, "Method") != 0) {
continue;
}
/* Extract function body from source */
const char *p = source;
int line = 1;
while (*p && line < def->start_line) {
if (*p == '\n') {
line++;
}
p++;
}
const char *body_start = p;
while (*p && line < def->end_line) {
if (*p == '\n') {
line++;
}
p++;
}
size_t body_len = (size_t)(p - body_start);
char *body = malloc(body_len + 1);
if (!body) {
continue;
}
memcpy(body, body_start, body_len);
body[body_len] = '\0';
/* Match each DLL resolution pattern */
for (int pi = 0; pi < 3; pi++) {
cbm_regmatch_t match[2];
const char *scan = body;
while (cbm_regexec(patterns[pi], scan, 2, match, 0) == 0) {
int fn_len = match[1].rm_eo - match[1].rm_so;
char func_name[256];
if (fn_len >= (int)sizeof(func_name)) {
fn_len = (int)sizeof(func_name) - 1;
}
memcpy(func_name, scan + match[1].rm_so, (size_t)fn_len);
func_name[fn_len] = '\0';
/* Create edge to synthetic DLL stub node */
char target_qn[512];
snprintf(target_qn, sizeof(target_qn), "%s.dll.external.%s", module_qn,
func_name);
const cbm_gbuf_node_t *src_node =
cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name);
if (src_node) {
/* Create stub node if it doesn't exist */
if (!cbm_gbuf_find_by_qn(ctx->gbuf, target_qn)) {
char stub_props[256];
snprintf(stub_props, sizeof(stub_props),
"{\"stub\":true,\"source\":\"dll_resolve\","
"\"dll_function\":\"%s\"}",
func_name);
cbm_gbuf_upsert_node(ctx->gbuf, "Function", func_name, target_qn,
files[i].rel_path, (int)def->start_line,
(int)def->start_line, stub_props);
}
const cbm_gbuf_node_t *tgt_node = cbm_gbuf_find_by_qn(ctx->gbuf, target_qn);
if (tgt_node) {
cbm_gbuf_insert_edge(ctx->gbuf, src_node->id, tgt_node->id, "CALLS",
"{\"confidence\":0.85,\"strategy\":\"dll_resolve\""
"}");
edge_count++;
}
}
scan += match[0].rm_eo;
}
}
free(body);
}
free(module_qn);
free(source);
}
cbm_regfree(&getproc_re);
cbm_regfree(&dlsym_re);
cbm_regfree(&resolve_re);
if (edge_count > 0) {
cbm_log_info("pass.dll_resolve", "edges", itoa_log(edge_count));
}
}
+6
View File
@@ -355,6 +355,12 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t
int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count); int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count);
/* Sub-passes called from pass_calls: pattern-based edge extraction */
void cbm_pipeline_pass_fastapi_depends(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count);
void cbm_pipeline_pass_dll_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
int file_count);
int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count); int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int file_count);
int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, int cbm_pipeline_pass_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
+34
View File
@@ -780,6 +780,37 @@ TEST(httplink_linker_same_service_skip) {
PASS(); PASS();
} }
/* ── Laravel path filter: reject $ and : in matched paths ──────── */
TEST(httplink_laravel_path_filter) {
cbm_route_handler_t routes[8];
int n;
/* Cache key patterns should be filtered (contain $ or :) */
n = cbm_extract_laravel_routes("fn", "proj.fn",
"Cache::get('article:{$this->id}:image')", routes, 8);
ASSERT_EQ(n, 0);
n = cbm_extract_laravel_routes("fn", "proj.fn",
"Route::get(\"cache:$key\", fn() => null)", routes, 8);
ASSERT_EQ(n, 0);
/* Valid routes should still pass (Laravel uses {param} not $param) */
n = cbm_extract_laravel_routes("fn", "proj.fn",
"Route::get('/api/users/{id}', 'UserController@show')", routes,
8);
ASSERT_EQ(n, 1);
ASSERT_STR_EQ(routes[0].path, "/api/users/{id}");
/* Route with no special chars also passes */
n = cbm_extract_laravel_routes("fn", "proj.fn",
"Route::post('/api/login', 'AuthController@login')", routes, 8);
ASSERT_EQ(n, 1);
ASSERT_STR_EQ(routes[0].path, "/api/login");
PASS();
}
/* ── Laravel module-level route extraction ─────────────────────── */ /* ── Laravel module-level route extraction ─────────────────────── */
TEST(httplink_laravel_module_level_routes) { TEST(httplink_laravel_module_level_routes) {
@@ -863,6 +894,9 @@ SUITE(httplink) {
RUN_TEST(httplink_read_source_lines); RUN_TEST(httplink_read_source_lines);
RUN_TEST(httplink_read_source_lines_missing_file); RUN_TEST(httplink_read_source_lines_missing_file);
/* Laravel path filter (1 test) */
RUN_TEST(httplink_laravel_path_filter);
/* Integration (3 tests) */ /* Integration (3 tests) */
RUN_TEST(httplink_linker_route_nodes); RUN_TEST(httplink_linker_route_nodes);
RUN_TEST(httplink_linker_same_service_skip); RUN_TEST(httplink_linker_same_service_skip);
+130
View File
@@ -4741,6 +4741,132 @@ static void cleanup_incremental_repo(void) {
(void)system(cmd); (void)system(cmd);
} }
/* ═══════════════════════════════════════════════════════════════════
* FastAPI Depends() edge tracking (PR #66, fix #27)
* ═══════════════════════════════════════════════════════════════════ */
TEST(pipeline_fastapi_depends_edges) {
/* Depends(get_current_user) should produce a CALLS edge from the
* endpoint to the dependency function. */
const char *files[] = {"auth.py", "routes.py"};
const char *contents[] = {
/* auth.py: defines get_current_user */
"def get_current_user(token: str):\n"
" return decode_token(token)\n",
/* routes.py: endpoint depends on get_current_user */
"from fastapi import Depends\n"
"from auth import get_current_user\n\n"
"def get_profile(user = Depends(get_current_user)):\n"
" return {\"user\": user}\n"};
if (setup_lang_repo(files, contents, 2) != 0) {
SKIP("tmpdir");
}
char db[512];
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_store_t *s = cbm_store_open_path(db);
ASSERT_NOT_NULL(s);
const char *proj = cbm_pipeline_project_name(p);
/* Check CALLS edges for fastapi_depends strategy */
cbm_edge_t *edges = NULL;
int edge_count = 0;
cbm_store_find_edges_by_type(s, proj, "CALLS", &edges, &edge_count);
bool found_depends_edge = false;
for (int i = 0; i < edge_count; i++) {
if (edges[i].properties_json &&
strstr(edges[i].properties_json, "fastapi_depends")) {
found_depends_edge = true;
break;
}
}
if (edges) {
cbm_store_free_edges(edges, edge_count);
}
ASSERT_TRUE(found_depends_edge);
cbm_store_close(s);
cbm_pipeline_free(p);
teardown_lang_repo();
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* DLL resolve edge tracking (PR #66, fix #29)
* ═══════════════════════════════════════════════════════════════════ */
TEST(pipeline_dll_resolve_edges) {
/* GetProcAddress(handle, "Func") should produce a CALLS edge to a
* synthetic DLL stub node. */
const char *files[] = {"loader.c"};
const char *contents[] = {
"#include <windows.h>\n\n"
"void load_plugin(HMODULE hDll) {\n"
" void *fn = GetProcAddress(hDll, \"PluginInit\");\n"
" if (fn) ((void(*)())fn)();\n"
"}\n"};
if (setup_lang_repo(files, contents, 1) != 0) {
SKIP("tmpdir");
}
char db[512];
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_store_t *s = cbm_store_open_path(db);
ASSERT_NOT_NULL(s);
const char *proj = cbm_pipeline_project_name(p);
/* Check for dll_resolve CALLS edge */
cbm_edge_t *edges = NULL;
int edge_count = 0;
cbm_store_find_edges_by_type(s, proj, "CALLS", &edges, &edge_count);
bool found_dll_edge = false;
for (int i = 0; i < edge_count; i++) {
if (edges[i].properties_json && strstr(edges[i].properties_json, "dll_resolve")) {
found_dll_edge = true;
break;
}
}
if (edges) {
cbm_store_free_edges(edges, edge_count);
}
ASSERT_TRUE(found_dll_edge);
/* Check for stub node with dll_function property */
cbm_node_t *nodes = NULL;
int node_count = 0;
cbm_store_find_nodes_by_label(s, proj, "Function", &nodes, &node_count);
bool found_stub = false;
for (int i = 0; i < node_count; i++) {
if (nodes[i].properties_json && strstr(nodes[i].properties_json, "PluginInit") &&
strstr(nodes[i].properties_json, "dll_resolve")) {
found_stub = true;
break;
}
}
if (nodes) {
cbm_store_free_nodes(nodes, node_count);
}
ASSERT_TRUE(found_stub);
cbm_store_close(s);
cbm_pipeline_free(p);
teardown_lang_repo();
PASS();
}
/* ═══════════════════════════════════════════════════════════════════
* Incremental reindex
* ═══════════════════════════════════════════════════════════════════ */
TEST(incremental_full_then_noop) { TEST(incremental_full_then_noop) {
/* Full index, then re-run → should detect no changes and skip */ /* Full index, then re-run → should detect no changes and skip */
if (setup_incremental_repo() != 0) { SKIP("setup failed"); } if (setup_incremental_repo() != 0) { SKIP("setup failed"); }
@@ -5062,6 +5188,10 @@ SUITE(pipeline) {
RUN_TEST(githistory_coupling_skips_large_commits); RUN_TEST(githistory_coupling_skips_large_commits);
RUN_TEST(githistory_coupling_limits_output); RUN_TEST(githistory_coupling_limits_output);
/* Incremental reindex */ /* Incremental reindex */
/* FastAPI Depends + DLL resolve edge tracking (PR #66 port) */
RUN_TEST(pipeline_fastapi_depends_edges);
RUN_TEST(pipeline_dll_resolve_edges);
/* Incremental */
RUN_TEST(incremental_full_then_noop); RUN_TEST(incremental_full_then_noop);
RUN_TEST(incremental_detects_changed_file); RUN_TEST(incremental_detects_changed_file);
RUN_TEST(incremental_detects_deleted_file); RUN_TEST(incremental_detects_deleted_file);