Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions internal/cbm/service_patterns.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions internal/cbm/service_patterns.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -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 ≈
Expand Down
9 changes: 8 additions & 1 deletion src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down
14 changes: 14 additions & 0 deletions src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
143 changes: 143 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading