fix(pipeline): classify native fetch() as HTTP_CALLS, not local shadows

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>
This commit is contained in:
Alexandros Pappas committed 2026-07-07 11:33:56 +08:00
1 parent ce23ce75e0
commit d288a21ec2
5 files changed
+181 -1

No files matched your search

+4
View File
@@ -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;
+12
View File
@@ -11,6 +11,8 @@
#ifndef CBM_SERVICE_PATTERNS_H
#define CBM_SERVICE_PATTERNS_H
#include <stdbool.h>
/* 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 ≈
+8 -1
View File
@@ -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' &&
+14
View File
@@ -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;
}
+143
View File
@@ -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);