diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 053fe9b2d..5ff5abeb5 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -782,6 +782,10 @@ void cbm_service_patterns_init(void) { /* No-op — tables are static const */ } +bool cbm_service_pattern_is_global_fetch(const char *callee_name) { + return callee_name != NULL && strcmp(callee_name, "fetch") == 0; +} + cbm_svc_kind_t cbm_service_pattern_match(const char *resolved_qn) { if (!resolved_qn || !resolved_qn[0]) { return CBM_SVC_NONE; diff --git a/internal/cbm/service_patterns.h b/internal/cbm/service_patterns.h index 8a87c63fa..28cbfe1b9 100644 --- a/internal/cbm/service_patterns.h +++ b/internal/cbm/service_patterns.h @@ -11,6 +11,8 @@ #ifndef CBM_SERVICE_PATTERNS_H #define CBM_SERVICE_PATTERNS_H +#include + /* Edge type returned by pattern match. */ typedef enum { CBM_SVC_NONE = 0, /* Not a service pattern — use normal CALLS */ @@ -32,6 +34,16 @@ void cbm_service_patterns_init(void); * "project.venv.requests.api.get"). Import-alias transparent. */ cbm_svc_kind_t cbm_service_pattern_match(const char *resolved_qn); +/* True for a bare, unqualified call to the native `fetch()` API. Deliberately + * NOT part of the substring tables above: those are matched unconditionally + * against the raw callee name before/regardless of registry resolution + * (the #523 external-library bypass), and "fetch" — unlike "axios" or + * "requests" — collides with a plausible local identifier. Callers must + * only consult this after registry resolution has come back empty, so a + * locally resolvable `function fetch(){}` / `const fetch = () => {}` is + * classified via its real resolved QN instead and never reaches this check. */ +bool cbm_service_pattern_is_global_fetch(const char *callee_name); + /* Per-worker TLS cache for cbm_service_pattern_match results. The * pattern matcher runs once per resolved CALL edge in emit_service_ * edge — that's 6 pattern lists × ~30 patterns × strstr per call ≈ diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 38087fc51..7ed132d98 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -506,8 +506,15 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * HTTP_CALLS/ASYNC_CALLS edge directly (target is a synthesized route * node, not the absent library). Without this the call is dropped and * cross-repo matching finds no edge to match (#523). The parallel path - * has the equivalent empty-resolution fallback in resolve_file_calls. */ + * has the equivalent empty-resolution fallback in resolve_file_calls. + * + * Native `fetch()` (#856) belongs here too, not in the substring + * tables above: it only counts as the global API once resolution has + * already failed to find a local/imported `fetch` definition. */ cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name); + if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { + esvc = CBM_SVC_HTTP; + } if (esvc == CBM_SVC_HTTP || esvc == CBM_SVC_ASYNC) { const char *u = call->first_string_arg; bool has_url_or_topic = u && u[0] != '\0' && diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 8e0d35691..bee5b0c58 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2299,6 +2299,20 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count, false); + } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { + /* Native `fetch()` (#856): only the global API once resolution + * has failed to find a local/imported `fetch`. Call the low-level + * emitter directly — emit_service_edge re-derives its own kind + * from res->qualified_name via cbm_service_pattern_match, which + * "fetch" deliberately never matches (mirrors pass_calls.c). */ + const char *u = call->first_string_arg; + if (u && u[0] != '\0' && (u[0] == '/' || strstr(u, "://") != NULL)) { + cbm_resolution_t fake_res = {.qualified_name = call->callee_name, + .confidence = PP_HALF_CONF, + .strategy = "service_pattern"}; + emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &fake_res, + CBM_SVC_HTTP, u); + } } continue; } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 1b91fb227..1f2a7c48b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1129,6 +1129,146 @@ TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) { PASS(); } +/* Native `fetch()` (#856), sequential path (< 50 files → pass_calls.c). A bare + * unqualified call to the global fetch API has no import and no local + * definition anywhere in this project, so registry resolution comes back + * empty; classify it as HTTP_CALLS via the same #523-style empty-resolution + * fallback used for axios/requests. A member call on an unrelated receiver + * (`repo.fetch()`) must NOT be swept in — its callee_name is "repo.fetch", + * not the bare "fetch" this check matches exactly. */ +TEST(pipeline_native_fetch_classified_as_http_calls) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/api.ts", + "export function loadData(): unknown {\n" + " return fetch('/api/data');\n" + "}\n"); + /* `repo.fetch()` — a method call, not the global. Must not over-match. */ + write_temp_file(tmp, "src/repo.ts", + "export function useRepo(repo: { fetch: () => unknown }): unknown {\n" + " return repo.fetch();\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/fetch.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1); + /* Exactly the bare call, not the method call too. */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's + * resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls + * but forces the parallel resolver, since the empty-resolution fallback is a + * separate implementation there (resolve_file_calls calls + * emit_http_async_service_edge directly rather than through emit_service_edge, + * which would otherwise re-derive CBM_SVC_NONE for "fetch" and silently fall + * through to a plain CALLS edge). */ +TEST(pipeline_native_fetch_parallel_classified_as_http_calls) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/api.ts", + "export function loadData(): unknown {\n" + " return fetch('/api/data');\n" + "}\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); /* force parallel regardless of host cores */ + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/fetch_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +/* Native `fetch()` (#856): a LOCAL `fetch` definition must win over the + * global-API guess. Registry resolution finds this project's own top-level + * `fetch` function, so the call is a plain CALLS edge to it — never + * HTTP_CALLS. Isolated in its own project: the registry's project-wide + * unique-name fallback means a local `fetch` anywhere in a project can + * legitimately capture bare `fetch()` calls project-wide, so this must not + * share a project with the genuine-native-fetch test above. */ +TEST(pipeline_local_fetch_shadow_not_classified_as_http) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_shadow_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/local_fetch.ts", + "function fetch(url: string): string {\n" + " return 'mock:' + url;\n" + "}\n" + "export function useLocalFetch(): string {\n" + " return fetch('/api/data');\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/fetch_shadow.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 0); + ASSERT_TRUE(cross_file_call_exists(s, project, "useLocalFetch", "fetch")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* ── Git history pass tests ─────────────────────────────────────── */ TEST(githistory_is_trackable) { @@ -6734,6 +6874,9 @@ SUITE(pipeline) { RUN_TEST(pipeline_incremental_preserves_cross_file_calls); RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); + RUN_TEST(pipeline_native_fetch_classified_as_http_calls); + RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls); + RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); /* Git history pass */ RUN_TEST(githistory_is_trackable); RUN_TEST(githistory_compute_coupling);