diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 0bcf14221..3a28cee39 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -386,6 +386,8 @@ typedef enum { typedef struct { const char *local_name; // local alias or name const char *module_path; // resolved module path / QN + const char *member; // ES named import: the exported name the local binds (`get` in + // `import { get as navigate } from "axios"`); NULL otherwise bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) CBMImportKind kind; // CBM_IMPORT_KIND_DEFAULT unless the syntax names a member kind } CBMImport; diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index f3d627cd0..313168bce 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -377,9 +377,12 @@ static bool process_named_imports(CBMExtractCtx *ctx, TSNode sub, const char *pa orig = ts_node_child(imp_spec, 0); } if (!ts_node_is_null(orig)) { - char *local_name = !ts_node_is_null(local) ? cbm_node_text(a, local, ctx->source) - : cbm_node_text(a, orig, ctx->source); - CBMImport imp = {.local_name = local_name, .module_path = path}; + char *member = cbm_node_text(a, orig, ctx->source); + char *local_name = + !ts_node_is_null(local) ? cbm_node_text(a, local, ctx->source) : member; + /* The member the local binds survives an alias: a call through + * `navigate` from `{ get as navigate }` is the module's `get`. */ + CBMImport imp = {.local_name = local_name, .module_path = path, .member = member}; cbm_imports_push(&ctx->result->imports, a, imp); found = true; } diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index cb77ead58..f64ee50ff 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -316,6 +316,7 @@ static void cr_walk(cr_ctx_t *c, CBMFileResult *r) { for (int i = 0; i < r->imports.count && r->imports.items; i++) { cr_str(c, &r->imports.items[i].local_name); cr_str(c, &r->imports.items[i].module_path); + cr_str(c, &r->imports.items[i].member); } cr_array(c, (void **)&r->usages.items, r->usages.count, sizeof(CBMUsage)); for (int i = 0; i < r->usages.count && r->usages.items; i++) { diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 0be448885..7682a0acb 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -379,6 +379,185 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje return n > 0 && (size_t)n < out_sz; } +/* ── Calls through an import of an external library ───────────────── + * See cbm_pipeline_external_import_service in pipeline_internal.h. */ + +/* Index value for a name no external binding may answer for: a project or + * relative import, or a name two imports bind differently. */ +static const char pc_import_blocked[] = ""; + +static bool pc_external_import_lang(CBMLanguage lang) { + return lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || + lang == CBM_LANG_TSX; +} + +/* Whether an import can name an external module at all: a relative path + * never does, nor (JS/TS) an absolute path or a project-root alias. */ +static bool pc_import_may_be_external(CBMLanguage lang, const CBMImport *imp) { + const char *m = imp->module_path; + if (!m || !m[0] || m[0] == '.' || strstr(m, " as ")) { + return false; + } + if (lang == CBM_LANG_PYTHON) { + return true; + } + return m[0] != '/' && m[0] != '~' && m[0] != '#' && strncmp(m, "@/", PAIR_LEN) != 0; +} + +/* Two imports bind the same name to the same thing. */ +static bool pc_same_binding(const CBMImport *a, const CBMImport *b) { + return a->is_default == b->is_default && strcmp(a->module_path, b->module_path) == 0 && + (a->member == b->member || + (a->member && b->member && strcmp(a->member, b->member) == 0)); +} + +/* The callee as its import makes it; false when the import adds nothing to + * the callee's own spelling. + * Python binds `local` to module_path: `from m import x as y` and + * `import m.x as y` both store y -> m.x, so `y.f` is m.x.f. A plain + * `import a.b` binds the root `a` and the callee already spells its path. + * JS/TS: a named import binds the module's member, alias or not + * (`navigate` under `import { get as navigate } from 'axios'` is + * axios.get); a default or namespace import binds the module itself, so + * `ax.get` is axios.get. */ +static bool pc_spell_through_import(CBMLanguage lang, const CBMImport *imp, const char *callee, + size_t root_len, char *out, size_t out_sz) { + static const char node_scheme[] = "node:"; + const char *module = imp->module_path; + const char *rest = callee + root_len; + int n; + if (lang == CBM_LANG_PYTHON) { + size_t local_len = strlen(imp->local_name); + if (strncmp(module, imp->local_name, local_len) == 0 && + (module[local_len] == '\0' || module[local_len] == '.')) { + return false; + } + n = snprintf(out, out_sz, "%s%s", module, rest); + } else { + if (strncmp(module, node_scheme, sizeof(node_scheme) - SKIP_ONE) == 0) { + module += sizeof(node_scheme) - SKIP_ONE; + } + n = imp->member ? snprintf(out, out_sz, "%s.%s%s", module, imp->member, rest) + : snprintf(out, out_sz, "%s%s", module, rest); + } + return n > 0 && (size_t)n < out_sz; +} + +/* Index every name the file's imports bind; then block each one an IMPORTS + * edge materialized (a project module, whatever its spelling) and each one + * bound twice to different things. */ +static void pc_external_import_index_build(cbm_external_import_index_t *index, + const cbm_gbuf_t *gbuf, const char *project, + const char *rel, CBMLanguage lang, + const CBMImportArray *imports) { + index->built = true; + if (!imports || imports->count <= 0) { + return; + } + index->by_local = cbm_ht_create((uint32_t)imports->count * PAIR_LEN + CBM_SZ_8); + if (!index->by_local) { + return; + } + void *blocked = (void *)pc_import_blocked; + for (int i = 0; i < imports->count; i++) { + const CBMImport *imp = &imports->items[i]; + if (!imp->local_name || !imp->local_name[0]) { + continue; + } + const CBMImport *prev = cbm_ht_get(index->by_local, imp->local_name); + bool external = pc_import_may_be_external(lang, imp); + if (!prev) { + cbm_ht_set(index->by_local, imp->local_name, external ? (void *)imp : blocked); + } else if ((const void *)prev != blocked && (!external || !pc_same_binding(prev, imp))) { + cbm_ht_set(index->by_local, imp->local_name, blocked); + } + } + const cbm_gbuf_node_t *file_node = pc_find_by_computed_qn(gbuf, project, rel, "__file__"); + const cbm_gbuf_edge_t **edges = NULL; + int edge_count = 0; + if (!file_node || cbm_gbuf_find_edges_by_source_type(gbuf, file_node->id, "IMPORTS", &edges, + &edge_count) != 0) { + return; + } + static const char key_open[] = "\"local_name\":\""; + for (int i = 0; i < edge_count; i++) { + const char *start = + edges[i]->properties_json ? strstr(edges[i]->properties_json, key_open) : NULL; + const char *end = start ? strchr(start + sizeof(key_open) - SKIP_ONE, '"') : NULL; + if (!end) { + continue; + } + start += sizeof(key_open) - SKIP_ONE; + char local[CBM_SZ_256]; + size_t len = (size_t)(end - start); + if (len == 0 || len >= sizeof(local)) { + continue; + } + memcpy(local, start, len); + local[len] = '\0'; + const char *key = cbm_ht_get_key(index->by_local, local); + if (key) { + cbm_ht_set(index->by_local, key, blocked); + } + } +} + +void cbm_pipeline_external_import_index_free(cbm_external_import_index_t *index) { + if (!index) { + return; + } + if (index->by_local) { + cbm_ht_free(index->by_local); + } + index->by_local = NULL; + index->built = false; +} + +cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t *index, + const cbm_gbuf_t *gbuf, const char *project, + const char *rel, CBMLanguage lang, + const CBMImportArray *imports, + const CBMCall *call, char *spelled, + size_t spelled_sz) { + const char *callee = call ? call->callee_name : NULL; + const char *u = call ? call->first_string_arg : NULL; + if (!index || !gbuf || !spelled || spelled_sz == 0 || !callee || !callee[0] || !u || !u[0] || + !pc_external_import_lang(lang) || call->requires_lsp_resolution || + (lang == CBM_LANG_PYTHON && call->callee_is_locally_bound) || + cbm_service_pattern_route_method(callee) != NULL || + cbm_service_pattern_is_global_fetch(callee)) { + return CBM_SVC_NONE; + } + const char *dot = strchr(callee, '.'); + size_t root_len = dot ? (size_t)(dot - callee) : strlen(callee); + char root[CBM_SZ_256]; + if (root_len == 0 || root_len >= sizeof(root)) { + return CBM_SVC_NONE; + } + memcpy(root, callee, root_len); + root[root_len] = '\0'; + if (!index->built) { + pc_external_import_index_build(index, gbuf, project, rel, lang, imports); + } + const CBMImport *imp = index->by_local ? cbm_ht_get(index->by_local, root) : NULL; + if (!imp || (const void *)imp == (const void *)pc_import_blocked || + !pc_spell_through_import(lang, imp, callee, root_len, spelled, spelled_sz)) { + spelled[0] = '\0'; + return CBM_SVC_NONE; + } + cbm_svc_kind_t svc = cbm_service_pattern_match(spelled); + bool is_url = u[0] == '/' || strstr(u, "://") != NULL; + bool is_topic = svc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN; + /* An HTTP library also exports helpers that take a URL without sending + * one (urllib.parse.urljoin): only a request verb is a request. */ + bool is_request = svc == CBM_SVC_HTTP && cbm_service_pattern_http_method(spelled) != NULL; + if ((is_request && is_url) || (svc == CBM_SVC_ASYNC && (is_url || is_topic))) { + return svc; + } + spelled[0] = '\0'; + return CBM_SVC_NONE; +} + /* Handle a route registration call: create Route node + HANDLES edge. * route_mount is the framework mount of the registering file ("/api" for a * Laravel 11+ `withRouting(api: ...)` file, #1146) or "". */ @@ -672,7 +851,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM const char *rel, const char *module_qn, const char **imp_keys, const char **imp_vals, int imp_count, CBMLanguage lang, const CBMImportArray *imports, const char *route_mount, - const cbm_php_vendor_names_t *php_vendor) { + const cbm_php_vendor_names_t *php_vendor, + cbm_external_import_index_t *ext_imports) { const CBMResolvedCallArray *lsp_calls = &result->resolved_calls; const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn); if (!source_node) { @@ -862,6 +1042,23 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM return SKIP_ONE; } } + /* A callee an import binds to an external client library + * (`send` under `from paho.mqtt import publish as send`): spelled + * through its import, then classified. MUST match pass_parallel.c. */ + char spelled[CBM_SZ_512]; + cbm_svc_kind_t isvc = + cbm_pipeline_external_import_service(ext_imports, ctx->gbuf, ctx->project_name, rel, + lang, imports, call, spelled, sizeof(spelled)); + if (isvc != CBM_SVC_NONE) { + CBMCall through = *call; + through.callee_name = spelled; + cbm_resolution_t imp_res = {.qualified_name = spelled, + .confidence = PC_SVC_PATTERN_CONF, + .strategy = "import_binding", + .candidate_count = 0}; + emit_http_async_edge(ctx, &through, source_node, NULL, &imp_res, isvc, false); + return SKIP_ONE; + } return 0; } @@ -1173,6 +1370,7 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file sizeof(route_mount)); /* Resolve each call */ + cbm_external_import_index_t ext_imports = {0}; for (int c = 0; c < result->calls.count; c++) { CBMCall *call = &result->calls.items[c]; if (!call->callee_name) { @@ -1181,12 +1379,13 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file total_calls++; if (resolve_single_call(ctx, call, result, rel, module_qn, imp_keys, imp_vals, imp_count, files[i].language, &result->imports, route_mount, - &php_vendor)) { + &php_vendor, &ext_imports)) { resolved++; } else { unresolved++; } } + cbm_pipeline_external_import_index_free(&ext_imports); free(module_qn); cbm_pipeline_php_vendor_names_free(&php_vendor); diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 7144a8963..19393ac48 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3075,6 +3075,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool lsp_exact_idx_complete = true; bool lsp_legacy_idx_complete = true; bool allow_tail = cbm_pipeline_lsp_allow_tail_match(lang); + /* Names this file's imports bind onto external libraries, built on the + * first unresolved call that asks (see pass_calls.c). */ + cbm_external_import_index_t ext_imports = {0}; if (result->calls.count > 0 && result->resolved_calls.count > 0) { uint32_t capacity = (uint32_t)result->resolved_calls.count * 2u + 16u; lsp_exact_idx = cbm_ht_create(capacity); @@ -3411,6 +3414,24 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &fake_res, CBM_SVC_HTTP, u); } + } else { + /* A callee an import binds to an external client library + * (`send` under `from paho.mqtt import publish as send`): + * spelled through its import, then classified. MUST match + * pass_calls.c. */ + char spelled[CBM_SZ_512]; + cbm_svc_kind_t isvc = cbm_pipeline_external_import_service( + &ext_imports, rc->main_gbuf, rc->project_name, rel, lang, &result->imports, + call, spelled, sizeof(spelled)); + if (isvc != CBM_SVC_NONE) { + CBMCall through = *call; + through.callee_name = spelled; + cbm_resolution_t imp_res = {.qualified_name = spelled, + .confidence = PP_HALF_CONF, + .strategy = "import_binding"}; + emit_http_async_service_edge(ws->local_edge_buf, source_node, &through, + &imp_res, isvc, through.first_string_arg); + } } continue; } @@ -3498,6 +3519,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_ht_foreach(lsp_legacy_idx, lsp_idx_free_key, NULL); cbm_ht_free(lsp_legacy_idx); } + cbm_pipeline_external_import_index_free(&ext_imports); cbm_pipeline_lsp_field_index_free(&field_index); } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 36ca30e7f..9b5134f58 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1978,7 +1978,7 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c size_t str_def_profile = 0, str_def_lists = 0, str_def_fp = 0, str_def_misc = 0; size_t str_call_names = 0, str_call_enclosing = 0, str_call_args = 0; size_t str_usage_names = 0, str_usage_enclosing = 0, str_rw = 0, str_typeref = 0; - size_t str_resolved = 0, str_source = 0, str_module = 0; + size_t str_resolved = 0, str_source = 0, str_module = 0, str_imports = 0; for (int i = 0; i < file_count; i++) { const CBMFileResult *r = cache ? cache[i] : NULL; if (!r) { @@ -2070,6 +2070,12 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c str_typeref += census_len(r->type_refs.items[t].type_name) + census_len(r->type_refs.items[t].enclosing_func_qn); } + for (int m = 0; m < r->imports.count; m++) { + const CBMImport *imp = &r->imports.items[m]; + /* A non-aliased named import's member IS its local name string. */ + str_imports += census_len(imp->local_name) + census_len(imp->module_path) + + (imp->member != imp->local_name ? census_len(imp->member) : 0); + } for (int q = 0; q < r->resolved_calls.count; q++) { const CBMResolvedCall *rc = &r->resolved_calls.items[q]; str_resolved += census_len(rc->caller_qn) + census_len(rc->callee_qn) + @@ -2102,10 +2108,11 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c cbm_log_info("extract.census.def_strings_mb", "tag", tag, "v", line); snprintf(line, sizeof(line), "call_names=%lu call_enclosing=%lu call_args=%lu usage_names=%lu " - "usage_enclosing=%lu rw=%lu type_refs=%lu resolved=%lu source=%lu module=%lu", + "usage_enclosing=%lu rw=%lu type_refs=%lu resolved=%lu source=%lu module=%lu " + "imports=%lu", MB(str_call_names), MB(str_call_enclosing), MB(str_call_args), MB(str_usage_names), MB(str_usage_enclosing), MB(str_rw), MB(str_typeref), MB(str_resolved), MB(str_source), - MB(str_module)); + MB(str_module), MB(str_imports)); cbm_log_info("extract.census.ref_strings_mb", "tag", tag, "v", line); #undef MB } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index eb5ec8df4..c3891f9a2 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -18,6 +18,7 @@ #include "git/git_context.h" #include "foundation/hash_table.h" #include "spawn_patterns.h" +#include "service_patterns.h" /* cbm_svc_kind_t */ #include "cbm.h" #include "lsp/php_lsp.h" #include "lsp/go_lsp.h" /* CBMLSPDef for cbm_parallel_resolve cross-LSP inputs */ @@ -669,6 +670,42 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje * behind both passes' HTTP_CALLS / ASYNC_CALLS props (pass_parallel.c). */ void cbm_pipeline_close_call_props(char *props, int n, const CBMCall *call, bool with_line); +/* Per-file index of the names a file's imports bind onto EXTERNAL modules: + * local name -> the binding import. Built lazily, on the first call that + * asks, from the file's extraction imports and its IMPORTS edges; one hash + * lookup per call after that. Zero-initialize, pass to + * cbm_pipeline_external_import_service for every call of ONE file, then + * release with cbm_pipeline_external_import_index_free. */ +typedef struct { + CBMHashTable *by_local; + bool built; +} cbm_external_import_index_t; + +void cbm_pipeline_external_import_index_free(cbm_external_import_index_t *index); + +/* A call no project symbol resolved, whose callee is bound by an import of an + * EXTERNAL library: `send(...)` under `from paho.mqtt import publish as send`, + * `navigate(...)` under `import { get as navigate } from 'axios'`. Spells the + * callee as its import makes it (paho.mqtt.publish, axios.get), runs the + * service patterns on that name, and returns CBM_SVC_HTTP or CBM_SVC_ASYNC + * (writing the spelled name to `spelled`) for an HTTP request -- the spelled + * name ends in a request verb, so urllib.parse.urljoin is none -- with a URL + * argument, or a messaging call with a URL or topic argument; CBM_SVC_NONE + * otherwise. + * + * Only adds what the resolvers had no answer for: a project import (one an + * IMPORTS edge materialized, or a relative path), a name bound by two + * different imports, a Python callee rebound as a parameter, and every call + * the route-suffix and global-fetch fallbacks already own are left alone. + * Python and JS/TS today; a language plugs in with its binding rule in + * pass_calls.c. Both call resolvers call this, so they cannot diverge. */ +cbm_svc_kind_t cbm_pipeline_external_import_service(cbm_external_import_index_t *index, + const cbm_gbuf_t *gbuf, const char *project, + const char *rel, CBMLanguage lang, + const CBMImportArray *imports, + const CBMCall *call, char *spelled, + size_t spelled_sz); + /* A spawn site (pass_spawns.c): the API the call names and the program it * starts (CBM_SPAWN_DYNAMIC when not a literal). */ typedef struct { diff --git a/tests/test_extraction_imports.c b/tests/test_extraction_imports.c index a9e0eb79f..1b1e93d6c 100644 --- a/tests/test_extraction_imports.c +++ b/tests/test_extraction_imports.c @@ -1254,6 +1254,62 @@ TEST(csharp_local_name_last_segment) { PASS(); } +/* ═══════════════════════════════════════════════════════════════════════════ + * ES named imports keep the member their local binds + * ═══════════════════════════════════════════════════════════════════════════ */ + +static const CBMImport *imp_by_local(const CBMFileResult *r, const char *local) { + for (int i = 0; i < r->imports.count; i++) { + if (r->imports.items[i].local_name && strcmp(r->imports.items[i].local_name, local) == 0) { + return &r->imports.items[i]; + } + } + return NULL; +} + +/* `navigate` from `{ get as navigate }` binds axios's `get`: the call + * resolvers spell a call through it as axios.get. The member is set for a + * named import only (aliased or not) and survives compaction into the + * result's one exact arena block. */ +TEST(es_named_import_member_survives_compaction) { + CBMFileResult *r = do_extract("import ax from 'axios';\n" + "import * as ns from 'axios';\n" + "import { get as navigate, post } from 'axios';\n", + CBM_LANG_TYPESCRIPT, "member.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + cbm_result_compact(r); + const CBMImport *aliased = imp_by_local(r, "navigate"); + const CBMImport *plain = imp_by_local(r, "post"); + const CBMImport *def = imp_by_local(r, "ax"); + const CBMImport *ns = imp_by_local(r, "ns"); + ASSERT_NOT_NULL(aliased); + ASSERT_NOT_NULL(plain); + ASSERT_NOT_NULL(def); + ASSERT_NOT_NULL(ns); + ASSERT_STR_EQ(aliased->module_path, "axios"); + ASSERT_STR_EQ(aliased->member, "get"); + ASSERT_STR_EQ(plain->member, "post"); + ASSERT_NULL(def->member); + ASSERT_NULL(ns->member); + uintptr_t member_addr = (uintptr_t)aliased->member; + uintptr_t arena_addr = (uintptr_t)r->arena.blocks[0]; + ASSERT_EQ(r->arena.nblocks, 1); + ASSERT(member_addr >= arena_addr); + ASSERT(member_addr - arena_addr < r->arena.used); + cbm_free_result(r); + + CBMFileResult *py = + do_extract("from paho.mqtt import publish as send\n", CBM_LANG_PYTHON, "member.py"); + ASSERT_NOT_NULL(py); + const CBMImport *send = imp_by_local(py, "send"); + ASSERT_NOT_NULL(send); + ASSERT_STR_EQ(send->module_path, "paho.mqtt.publish"); + ASSERT_NULL(send->member); + cbm_free_result(py); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * SUITE declaration — do NOT register in test_main.c; the main workstream will * ═══════════════════════════════════════════════════════════════════════════ */ @@ -1294,4 +1350,7 @@ SUITE(extraction_imports) { RUN_TEST(rust_local_name_last_segment); RUN_TEST(java_local_name_last_segment); RUN_TEST(csharp_local_name_last_segment); + + /* ES named-import member (call resolution spells calls through it) */ + RUN_TEST(es_named_import_member_survives_compaction); } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 0d14b8300..959c48bd3 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10360,6 +10360,183 @@ TEST(pipeline_relative_folded_wrapper_url_candidates) { PASS(); } +/* HTTP_CALLS + ASYNC_CALLS edges out of the function `source_name`. */ +static int service_edges_from(cbm_store_t *s, const char *project, const char *source_name) { + static const char *const types[] = {"HTTP_CALLS", "ASYNC_CALLS"}; + int matches = 0; + for (size_t t = 0; t < sizeof(types) / sizeof(types[0]); t++) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_type(s, project, types[t], &edges, &edge_count) != + CBM_STORE_OK) { + return -1; + } + for (int i = 0; i < edge_count; i++) { + cbm_node_t source = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &source) == CBM_STORE_OK && + source.name && strcmp(source.name, source_name) == 0) { + matches++; + } + cbm_node_free_fields(&source); + } + cbm_store_free_edges(edges, edge_count); + } + return matches; +} + +/* IMPORTS edges out of `file_path` that bind `local_name`; -1 on a failed query. */ +static int imports_binding_count(cbm_store_t *s, const char *project, const char *file_path, + const char *local_name) { + static const char sql[] = + "SELECT count(*) FROM edges e JOIN nodes f ON f.id=e.source_id WHERE e.project=?1 " + "AND e.type='IMPORTS' AND f.file_path=?2 AND json_extract(e.properties,'$.local_name')=?3"; + sqlite3 *db = cbm_store_get_db(s); + sqlite3_stmt *st = NULL; + if (!db || sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) { + sqlite3_finalize(st); + return -1; + } + int count = -1; + if (sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_bind_text(st, 2, file_path, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_bind_text(st, 3, local_name, -1, SQLITE_TRANSIENT) == SQLITE_OK && + sqlite3_step(st) == SQLITE_ROW) { + count = sqlite3_column_int(st, 0); + } + sqlite3_finalize(st); + return count; +} + +/* A call through an import of an EXTERNAL client library is that library's + * call: `fetchIt` from `{ get as fetchIt } from 'axios'` is a typed GET, + * `http_post` from `from requests import post as http_post` a typed POST, + * `emitIt` from `{ publish as emitIt } from 'mqtt'` a publish. Precision + * controls, each with no HTTP_CALLS/ASYNC_CALLS edge: + * - urljoin from urllib.parse: an HTTP library's helper, not a request; + * - lodash's `get`, json's `dumps`: libraries no service pattern knows; + * - `send` defined in the importing module shadows + * `from paho.mqtt import publish as send`; `relay` rebound as a parameter + * shadows `... as relay` (a name no project function has, so only the + * parameter binding stands between the call and the import); + * - `httpx` is a PROJECT package here: its import is materialized (the + * precondition is asserted), so a call it cannot resolve stays unresolved + * instead of becoming the external httpx.post. + * Both resolvers: sequential and parallel (> 50 files). */ +TEST(pipeline_external_import_binding_controls_sequential_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_extimport_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file( + tmp, "src/ext_http.ts", + "import { get as fetchIt } from 'axios';\n" + "import { get as lodashGet } from 'lodash';\n" + "import { publish as emitIt } from 'mqtt';\n" + "export function viaAxiosAlias(): unknown { return fetchIt('/api/alias-get'); }\n" + "export function viaLodashAlias(o: object): unknown {\n" + " return lodashGet(o, '/api/lodash');\n" + "}\n" + "export function viaMqtt(): void { emitIt('tenant/ts', 'payload'); }\n"); + write_temp_file(tmp, "src/ext_py.py", + "from requests import post as http_post\n" + "from urllib.parse import urljoin\n" + "from json import dumps as publish_json\n" + "from paho.mqtt import publish as send\n" + "def py_post():\n return http_post('/api/py-post')\n" + "def join_url(base):\n return urljoin(base, '/static/asset.css')\n" + "def join_abs(name):\n return urljoin('https://example.com/assets/', name)\n" + "def to_json():\n return publish_json('/tenant/json')\n" + "def send(topic, payload):\n return topic\n" + "def shadowed():\n return send('tenant/shadow', 'payload')\n"); + write_temp_file(tmp, "src/ext_param.py", + "from paho.mqtt import publish as relay\n" + "def param_shadow(relay):\n return relay('tenant/param', 'payload')\n"); + write_temp_file(tmp, "httpx/__init__.py", "def get(url):\n return url\n"); + write_temp_file(tmp, "src/proj_client.py", + "from httpx import post as hx_post\n" + "def proj_post():\n return hx_post('/api/project')\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/padding%d.ts", i); + snprintf(body, sizeof(body), "export function padding%d() { return %d; }\n", i, i); + write_temp_file(tmp, name, body); + } + static const char *const silent[] = {"viaLodashAlias", "join_url", "join_abs", "to_json", + "shadowed", "param_shadow", "proj_post"}; + enum { SILENT = sizeof(silent) / sizeof(silent[0]) }; + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + int run_rc[2] = {-1, -1}; + bool opened[2] = {false, false}; + int typed_get[2] = {-1, -1}; + int typed_post[2] = {-1, -1}; + int published[2] = {-1, -1}; + int shadow_calls[2] = {-1, -1}; + int project_import[2] = {-1, -1}; + int silent_edges[2][SILENT]; + for (int mode = 0; mode < 2; mode++) { + for (int i = 0; i < SILENT; i++) { + silent_edges[mode][i] = -1; + } + if (mode == 0) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/extimport_%d.db", tmp, mode); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + run_rc[mode] = p ? cbm_pipeline_run(p) : -1; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + cbm_store_t *s = cbm_store_open_path(db_path); + opened[mode] = s != NULL; + if (s && project) { + typed_get[mode] = http_calls_to_route(s, project, "viaAxiosAlias", "__route__GET__"); + typed_post[mode] = http_calls_to_route(s, project, "py_post", "__route__POST__"); + published[mode] = named_edge_count(s, project, "ASYNC_CALLS", "viaMqtt", "tenant/ts"); + shadow_calls[mode] = named_edge_count(s, project, "CALLS", "shadowed", "send"); + project_import[mode] = + imports_binding_count(s, project, "src/proj_client.py", "hx_post"); + for (int i = 0; i < SILENT; i++) { + silent_edges[mode][i] = service_edges_from(s, project, silent[i]); + } + } + if (s) { + cbm_store_close(s); + } + cbm_pipeline_free(p); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int mode = 0; mode < 2; mode++) { + ASSERT_EQ(run_rc[mode], 0); + ASSERT_TRUE(opened[mode]); + ASSERT_EQ(typed_get[mode], 1); + ASSERT_EQ(typed_post[mode], 1); + ASSERT_EQ(published[mode], 1); + ASSERT_GTE(shadow_calls[mode], 1); + ASSERT_GTE(project_import[mode], 1); + for (int i = 0; i < SILENT; i++) { + ASSERT_EQ(silent_edges[mode][i], 0); + } + } + PASS(); +} + /* ── Git history pass tests ─────────────────────────────────────── */ TEST(githistory_is_trackable) { @@ -21472,6 +21649,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); + RUN_TEST(pipeline_external_import_binding_controls_sequential_parallel); /* Git history pass */ RUN_TEST(githistory_is_trackable); RUN_TEST(githistory_compute_coupling);