Skip to content

Commit d288a21

Browse files
committed
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>
1 parent ce23ce7 commit d288a21

5 files changed

Lines changed: 181 additions & 1 deletion

File tree

internal/cbm/service_patterns.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,10 @@ void cbm_service_patterns_init(void) {
782782
/* No-op — tables are static const */
783783
}
784784

785+
bool cbm_service_pattern_is_global_fetch(const char *callee_name) {
786+
return callee_name != NULL && strcmp(callee_name, "fetch") == 0;
787+
}
788+
785789
cbm_svc_kind_t cbm_service_pattern_match(const char *resolved_qn) {
786790
if (!resolved_qn || !resolved_qn[0]) {
787791
return CBM_SVC_NONE;

internal/cbm/service_patterns.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
#ifndef CBM_SERVICE_PATTERNS_H
1212
#define CBM_SERVICE_PATTERNS_H
1313

14+
#include <stdbool.h>
15+
1416
/* Edge type returned by pattern match. */
1517
typedef enum {
1618
CBM_SVC_NONE = 0, /* Not a service pattern — use normal CALLS */
@@ -32,6 +34,16 @@ void cbm_service_patterns_init(void);
3234
* "project.venv.requests.api.get"). Import-alias transparent. */
3335
cbm_svc_kind_t cbm_service_pattern_match(const char *resolved_qn);
3436

37+
/* True for a bare, unqualified call to the native `fetch()` API. Deliberately
38+
* NOT part of the substring tables above: those are matched unconditionally
39+
* against the raw callee name before/regardless of registry resolution
40+
* (the #523 external-library bypass), and "fetch" — unlike "axios" or
41+
* "requests" — collides with a plausible local identifier. Callers must
42+
* only consult this after registry resolution has come back empty, so a
43+
* locally resolvable `function fetch(){}` / `const fetch = () => {}` is
44+
* classified via its real resolved QN instead and never reaches this check. */
45+
bool cbm_service_pattern_is_global_fetch(const char *callee_name);
46+
3547
/* Per-worker TLS cache for cbm_service_pattern_match results. The
3648
* pattern matcher runs once per resolved CALL edge in emit_service_
3749
* edge — that's 6 pattern lists × ~30 patterns × strstr per call ≈

src/pipeline/pass_calls.c

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,8 +506,15 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
506506
* HTTP_CALLS/ASYNC_CALLS edge directly (target is a synthesized route
507507
* node, not the absent library). Without this the call is dropped and
508508
* cross-repo matching finds no edge to match (#523). The parallel path
509-
* has the equivalent empty-resolution fallback in resolve_file_calls. */
509+
* has the equivalent empty-resolution fallback in resolve_file_calls.
510+
*
511+
* Native `fetch()` (#856) belongs here too, not in the substring
512+
* tables above: it only counts as the global API once resolution has
513+
* already failed to find a local/imported `fetch` definition. */
510514
cbm_svc_kind_t esvc = cbm_service_pattern_match(call->callee_name);
515+
if (esvc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) {
516+
esvc = CBM_SVC_HTTP;
517+
}
511518
if (esvc == CBM_SVC_HTTP || esvc == CBM_SVC_ASYNC) {
512519
const char *u = call->first_string_arg;
513520
bool has_url_or_topic = u && u[0] != '\0' &&

src/pipeline/pass_parallel.c

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,6 +2299,20 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
22992299
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
23002300
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
23012301
imp_count, false);
2302+
} else if (cbm_service_pattern_is_global_fetch(call->callee_name)) {
2303+
/* Native `fetch()` (#856): only the global API once resolution
2304+
* has failed to find a local/imported `fetch`. Call the low-level
2305+
* emitter directly — emit_service_edge re-derives its own kind
2306+
* from res->qualified_name via cbm_service_pattern_match, which
2307+
* "fetch" deliberately never matches (mirrors pass_calls.c). */
2308+
const char *u = call->first_string_arg;
2309+
if (u && u[0] != '\0' && (u[0] == '/' || strstr(u, "://") != NULL)) {
2310+
cbm_resolution_t fake_res = {.qualified_name = call->callee_name,
2311+
.confidence = PP_HALF_CONF,
2312+
.strategy = "service_pattern"};
2313+
emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &fake_res,
2314+
CBM_SVC_HTTP, u);
2315+
}
23022316
}
23032317
continue;
23042318
}

tests/test_pipeline.c

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,146 @@ TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) {
11291129
PASS();
11301130
}
11311131

1132+
/* Native `fetch()` (#856), sequential path (< 50 files → pass_calls.c). A bare
1133+
* unqualified call to the global fetch API has no import and no local
1134+
* definition anywhere in this project, so registry resolution comes back
1135+
* empty; classify it as HTTP_CALLS via the same #523-style empty-resolution
1136+
* fallback used for axios/requests. A member call on an unrelated receiver
1137+
* (`repo.fetch()`) must NOT be swept in — its callee_name is "repo.fetch",
1138+
* not the bare "fetch" this check matches exactly. */
1139+
TEST(pipeline_native_fetch_classified_as_http_calls) {
1140+
char tmp[256];
1141+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_XXXXXX");
1142+
if (!cbm_mkdtemp(tmp)) {
1143+
FAIL("tmpdir");
1144+
}
1145+
1146+
write_temp_file(tmp, "src/api.ts",
1147+
"export function loadData(): unknown {\n"
1148+
" return fetch('/api/data');\n"
1149+
"}\n");
1150+
/* `repo.fetch()` — a method call, not the global. Must not over-match. */
1151+
write_temp_file(tmp, "src/repo.ts",
1152+
"export function useRepo(repo: { fetch: () => unknown }): unknown {\n"
1153+
" return repo.fetch();\n"
1154+
"}\n");
1155+
1156+
char db_path[512];
1157+
snprintf(db_path, sizeof(db_path), "%s/fetch.db", tmp);
1158+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
1159+
ASSERT_NOT_NULL(p);
1160+
ASSERT_EQ(cbm_pipeline_run(p), 0);
1161+
const char *project = cbm_pipeline_project_name(p);
1162+
1163+
cbm_store_t *s = cbm_store_open_path(db_path);
1164+
ASSERT_NOT_NULL(s);
1165+
1166+
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
1167+
/* Exactly the bare call, not the method call too. */
1168+
ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
1169+
1170+
cbm_store_close(s);
1171+
cbm_pipeline_free(p);
1172+
th_rmtree(tmp);
1173+
PASS();
1174+
}
1175+
1176+
/* Native `fetch()` (#856), parallel path (>= 50 files -> pass_parallel.c's
1177+
* resolve_file_calls). Mirrors pipeline_native_fetch_classified_as_http_calls
1178+
* but forces the parallel resolver, since the empty-resolution fallback is a
1179+
* separate implementation there (resolve_file_calls calls
1180+
* emit_http_async_service_edge directly rather than through emit_service_edge,
1181+
* which would otherwise re-derive CBM_SVC_NONE for "fetch" and silently fall
1182+
* through to a plain CALLS edge). */
1183+
TEST(pipeline_native_fetch_parallel_classified_as_http_calls) {
1184+
char tmp[256];
1185+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_par_XXXXXX");
1186+
if (!cbm_mkdtemp(tmp)) {
1187+
FAIL("tmpdir");
1188+
}
1189+
1190+
write_temp_file(tmp, "src/api.ts",
1191+
"export function loadData(): unknown {\n"
1192+
" return fetch('/api/data');\n"
1193+
"}\n");
1194+
for (int i = 0; i < 52; i++) {
1195+
char name[64];
1196+
char body[128];
1197+
snprintf(name, sizeof(name), "src/filler%d.ts", i);
1198+
snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i,
1199+
i);
1200+
write_temp_file(tmp, name, body);
1201+
}
1202+
1203+
char *old_workers = getenv("CBM_WORKERS");
1204+
char *saved = old_workers ? strdup(old_workers) : NULL;
1205+
cbm_setenv("CBM_WORKERS", "4", 1); /* force parallel regardless of host cores */
1206+
1207+
char db_path[512];
1208+
snprintf(db_path, sizeof(db_path), "%s/fetch_par.db", tmp);
1209+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
1210+
ASSERT_NOT_NULL(p);
1211+
ASSERT_EQ(cbm_pipeline_run(p), 0);
1212+
const char *project = cbm_pipeline_project_name(p);
1213+
1214+
cbm_store_t *s = cbm_store_open_path(db_path);
1215+
ASSERT_NOT_NULL(s);
1216+
1217+
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 1);
1218+
1219+
cbm_store_close(s);
1220+
cbm_pipeline_free(p);
1221+
if (saved) {
1222+
cbm_setenv("CBM_WORKERS", saved, 1);
1223+
free(saved);
1224+
} else {
1225+
cbm_unsetenv("CBM_WORKERS");
1226+
}
1227+
th_rmtree(tmp);
1228+
PASS();
1229+
}
1230+
1231+
/* Native `fetch()` (#856): a LOCAL `fetch` definition must win over the
1232+
* global-API guess. Registry resolution finds this project's own top-level
1233+
* `fetch` function, so the call is a plain CALLS edge to it — never
1234+
* HTTP_CALLS. Isolated in its own project: the registry's project-wide
1235+
* unique-name fallback means a local `fetch` anywhere in a project can
1236+
* legitimately capture bare `fetch()` calls project-wide, so this must not
1237+
* share a project with the genuine-native-fetch test above. */
1238+
TEST(pipeline_local_fetch_shadow_not_classified_as_http) {
1239+
char tmp[256];
1240+
snprintf(tmp, sizeof(tmp), "/tmp/cbm_fetch_shadow_XXXXXX");
1241+
if (!cbm_mkdtemp(tmp)) {
1242+
FAIL("tmpdir");
1243+
}
1244+
1245+
write_temp_file(tmp, "src/local_fetch.ts",
1246+
"function fetch(url: string): string {\n"
1247+
" return 'mock:' + url;\n"
1248+
"}\n"
1249+
"export function useLocalFetch(): string {\n"
1250+
" return fetch('/api/data');\n"
1251+
"}\n");
1252+
1253+
char db_path[512];
1254+
snprintf(db_path, sizeof(db_path), "%s/fetch_shadow.db", tmp);
1255+
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
1256+
ASSERT_NOT_NULL(p);
1257+
ASSERT_EQ(cbm_pipeline_run(p), 0);
1258+
const char *project = cbm_pipeline_project_name(p);
1259+
1260+
cbm_store_t *s = cbm_store_open_path(db_path);
1261+
ASSERT_NOT_NULL(s);
1262+
1263+
ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 0);
1264+
ASSERT_TRUE(cross_file_call_exists(s, project, "useLocalFetch", "fetch"));
1265+
1266+
cbm_store_close(s);
1267+
cbm_pipeline_free(p);
1268+
th_rmtree(tmp);
1269+
PASS();
1270+
}
1271+
11321272
/* ── Git history pass tests ─────────────────────────────────────── */
11331273

11341274
TEST(githistory_is_trackable) {
@@ -6660,6 +6800,9 @@ SUITE(pipeline) {
66606800
RUN_TEST(pipeline_incremental_preserves_cross_file_calls);
66616801
RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge);
66626802
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
6803+
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
6804+
RUN_TEST(pipeline_native_fetch_parallel_classified_as_http_calls);
6805+
RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http);
66636806
/* Git history pass */
66646807
RUN_TEST(githistory_is_trackable);
66656808
RUN_TEST(githistory_compute_coupling);

0 commit comments

Comments
 (0)