From f92fd666b58db798cdacead4fea4127e8af739b6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 20 Mar 2026 19:19:12 +0100 Subject: [PATCH] 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 Co-Authored-By: mariomeyer --- src/pipeline/pass_calls.c | 309 +++++++++++++++++++++++++++++++ src/pipeline/pipeline_internal.h | 6 + tests/test_httplink.c | 34 ++++ tests/test_pipeline.c | 130 +++++++++++++ 4 files changed, 479 insertions(+) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index e59b21001..f7cf2bc0d 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -16,6 +16,8 @@ #include "foundation/compat.h" #include "cbm.h" +#include "foundation/compat_regex.h" + #include #include #include @@ -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", 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); + cbm_pipeline_pass_dll_resolve(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). */ + +// 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)); + } +} diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 38f35a45e..67b41f03a 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -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); +/* 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_semantic(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, diff --git a/tests/test_httplink.c b/tests/test_httplink.c index 14dc0f693..587680c8a 100644 --- a/tests/test_httplink.c +++ b/tests/test_httplink.c @@ -780,6 +780,37 @@ TEST(httplink_linker_same_service_skip) { 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 ─────────────────────── */ TEST(httplink_laravel_module_level_routes) { @@ -863,6 +894,9 @@ SUITE(httplink) { RUN_TEST(httplink_read_source_lines); RUN_TEST(httplink_read_source_lines_missing_file); + /* Laravel path filter (1 test) */ + RUN_TEST(httplink_laravel_path_filter); + /* Integration (3 tests) */ RUN_TEST(httplink_linker_route_nodes); RUN_TEST(httplink_linker_same_service_skip); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 8f6a6f6ad..747478b68 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4741,6 +4741,132 @@ static void cleanup_incremental_repo(void) { (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 \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) { /* Full index, then re-run → should detect no changes and skip */ 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_limits_output); /* 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_detects_changed_file); RUN_TEST(incremental_detects_deleted_file);