From d288a21ec2572916cd34753bc7bcc50afeb9c680 Mon Sep 17 00:00:00 2001 From: Alexandros Pappas <11921291+apappas1129@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:33:56 +0800 Subject: [PATCH] fix(pipeline): classify native fetch() as HTTP_CALLS, not local shadows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #856: native `fetch()` calls (Node 18+ built-in, and the identical browser/WHATWG API — no static way to tell them apart, and no need to: both make a real outbound HTTP request) were invisible to cross-repo intelligence. A bare `fetch(url)` has no import and typically no local definition, so registry resolution comes back empty and the call was silently dropped instead of becoming an HTTP_CALLS edge. The obvious fix — add "fetch" to the http_libraries substring table in service_patterns.c — is wrong: that table is consulted by an early, unconditional check in both pass_calls.c and pass_parallel.c that runs before/regardless of whether the callee resolves to a real local definition (by design, so `axios.get(url)` still classifies even when the registry mis-binds bare `get` to an unrelated local method). That's safe for "axios"/"requests" because nobody names their own function that; it is not safe for "fetch", which collides with a plausible local identifier (`function fetch(){}` or `const fetch = () => {}`). Instead, cbm_service_pattern_is_global_fetch() is a new, narrow, exact- match check consulted only in the *empty-resolution* fallback in both files — the existing #523 path for unindexed external libraries. By the time that branch runs, the registry has already had its chance to resolve "fetch" to a local/imported definition; only a genuine miss reaches the new check. This also means a member call like `repo.fetch()` is naturally excluded (its callee_name is "repo.fetch", not the bare "fetch" this checks for). pass_parallel.c's empty-resolution branch calls the low-level emit_http_async_service_edge() directly rather than going through emit_service_edge(), which re-derives its own classification from res->qualified_name via cbm_service_pattern_match() — a call with "fetch" would come back CBM_SVC_NONE there and silently fall through to a plain CALLS edge. Three new tests in test_pipeline.c: - bare fetch() -> HTTP_CALLS, sequential path (< 50 files) - bare fetch() -> HTTP_CALLS, forced through the parallel resolver (>= 50 files) - a local `function fetch(){}` shadowing the global -> plain CALLS to the local definition, zero HTTP_CALLS (the false-positive this PR must not introduce), bundled with a `repo.fetch()` member-call check in the first test Verified end-to-end, not just by inspection: prod build clean under -Wall -Wextra -Werror; test build clean under ASan+UBSan; full suite 5936 passed, 1 skipped (pre-existing, unrelated), 0 failed; clang-format clean on all touched files; lint-cppcheck's pre-existing failures (extract_defs.c, compat_regex.c) reproduced identically on unmodified main via stash/pop, confirming they predate this change. Refs #592. Signed-off-by: Alexandros Pappas <11921291+apappas1129@users.noreply.github.com> --- internal/cbm/service_patterns.c | 4 + internal/cbm/service_patterns.h | 12 +++ src/pipeline/pass_calls.c | 9 +- src/pipeline/pass_parallel.c | 14 ++++ tests/test_pipeline.c | 143 ++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) 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 da3957424..c1ff01191 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 fc35928a7..84bcb4b05 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) { @@ -6660,6 +6800,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);