diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index a00192a188..b8daec6e3a 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -238,6 +238,14 @@ typedef struct { * that declared this method. Kept at the tail so zero-initialised * callers in every other language remain ABI/source compatible. */ const char *impl_trait; + /* JS/TS only (#1916): a module-level binding initialised by + * `axios.create(...)` records the client library here ("axios"), and the + * literal `baseURL` string (or NULL when absent / not a string literal). + * The Module def carries the pair when its default export is such a + * client. The call resolver composes `.get('/p')` into an + * HTTP_CALLS edge to base + path. Tail fields: zero-init stays valid. */ + const char *http_client; + const char *http_base_url; } CBMDefinition; /* Argument captured from a call expression */ @@ -299,6 +307,7 @@ typedef struct { typedef struct { const char *local_name; // local alias or name const char *module_path; // resolved module path / QN + bool is_default; // ES default import (`import X from "Y"`), JS/TS only (#1916) } CBMImport; typedef enum { diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 916f1136f9..6fad812cc3 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5744,6 +5744,123 @@ static bool is_require_import_call(TSNode value, const char *source, CBMArena *a return false; } +/* True when text of `node` equals `want` exactly (no arena allocation). */ +static bool js_node_text_is(TSNode node, const char *source, const char *want) { + if (ts_node_is_null(node)) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(want); + return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0; +} + +/* #1916: `axios.create(...)` — the factory of a configured axios instance + * (the vue-element-admin / RuoYi `request.js` wrapper). Only the literal + * `axios` receiver is recognised: a look-alike `factory.create(...)` is not + * an HTTP client and must not turn its binding into one. */ +static bool js_is_axios_create_call(TSNode value, const char *source) { + if (ts_node_is_null(value) || strcmp(ts_node_type(value), "call_expression") != 0) { + return false; + } + TSNode fn = ts_node_child_by_field_name(value, TS_FIELD("function")); + if (ts_node_is_null(fn) || strcmp(ts_node_type(fn), "member_expression") != 0) { + return false; + } + TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object")); + TSNode prop = ts_node_child_by_field_name(fn, TS_FIELD("property")); + return !ts_node_is_null(obj) && strcmp(ts_node_type(obj), "identifier") == 0 && + js_node_text_is(obj, source, "axios") && js_node_text_is(prop, source, "create"); +} + +/* The literal `baseURL` of `axios.create({ baseURL: '' })`, or NULL when + * the config is absent, not an object literal, or the value is not a plain + * string literal (process.env.X, a template with substitutions, an escape): + * an unknown base is never guessed. */ +static const char *js_axios_create_base_url(CBMArena *a, TSNode call, const char *source) { + TSNode args = ts_node_child_by_field_name(call, TS_FIELD("arguments")); + if (ts_node_is_null(args) || ts_node_named_child_count(args) == 0) { + return NULL; + } + TSNode cfg = ts_node_named_child(args, 0); + if (strcmp(ts_node_type(cfg), "object") != 0) { + return NULL; + } + uint32_t n = ts_node_named_child_count(cfg); + for (uint32_t i = 0; i < n; i++) { + TSNode pair = ts_node_named_child(cfg, i); + if (strcmp(ts_node_type(pair), "pair") != 0) { + continue; + } + TSNode key = ts_node_child_by_field_name(pair, TS_FIELD("key")); + if (!js_node_text_is(key, source, "baseURL") && + !js_node_text_is(key, source, "'baseURL'") && + !js_node_text_is(key, source, "\"baseURL\"")) { + continue; + } + TSNode val = ts_node_child_by_field_name(pair, TS_FIELD("value")); + if (ts_node_is_null(val) || strcmp(ts_node_type(val), "string") != 0) { + return NULL; + } + char *text = cbm_node_text(a, val, source); + size_t len = text ? strlen(text) : 0; + if (len < PAIR_LEN || strchr(text, '\\') != NULL) { + return NULL; + } + text[len - SKIP_ONE] = '\0'; + return text + SKIP_ONE; + } + return NULL; +} + +/* Mark `def` as an axios client instance created by `call` (#1916). */ +static void js_mark_axios_client(CBMArena *a, CBMDefinition *def, TSNode call, const char *source) { + def->http_client = "axios"; + def->http_base_url = js_axios_create_base_url(a, call, source); +} + +/* #1916: `export default api;` (api an axios instance declared above) or + * `export default axios.create({...})` makes the MODULE's default export the + * client — record it on the Module def so a default import can find it. */ +static void js_mark_default_export_client(CBMExtractCtx *ctx, int mod_idx) { + if (mod_idx < 0 || mod_idx >= ctx->result->defs.count) { + return; + } + TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); + if (!ts_tree_cursor_goto_first_child(&cursor)) { + ts_tree_cursor_delete(&cursor); + return; + } + do { + TSNode stmt = ts_tree_cursor_current_node(&cursor); + if (strcmp(ts_node_type(stmt), "export_statement") != 0) { + continue; + } + TSNode val = ts_node_child_by_field_name(stmt, TS_FIELD("value")); + if (ts_node_is_null(val)) { + continue; + } + CBMDefinition *mod = &ctx->result->defs.items[mod_idx]; + if (js_is_axios_create_call(val, ctx->source)) { + js_mark_axios_client(ctx->arena, mod, val, ctx->source); + continue; + } + if (strcmp(ts_node_type(val), "identifier") != 0) { + continue; + } + for (int d = 0; d < ctx->result->defs.count; d++) { + const CBMDefinition *v = &ctx->result->defs.items[d]; + if (v->http_client && v->label && strcmp(v->label, "Variable") == 0 && v->name && + !v->parent_class && js_node_text_is(val, ctx->source, v->name)) { + mod->http_client = v->http_client; + mod->http_base_url = v->http_base_url; + break; + } + } + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + ts_tree_cursor_delete(&cursor); +} + // JS/TS variable extraction: skip function-assigned declarators. static void extract_js_vars(CBMExtractCtx *ctx, TSNode node, CBMArena *a) { uint32_t n = ts_node_named_child_count(node); @@ -5779,7 +5896,12 @@ static void extract_js_vars(CBMExtractCtx *ctx, TSNode node, CBMArena *a) { if (is_require) { continue; } + int before = ctx->result->defs.count; push_var_def(ctx, cbm_node_text(a, vname, ctx->source), child); + if (ctx->result->defs.count > before && + js_is_axios_create_call(value, ctx->source)) { + js_mark_axios_client(a, &ctx->result->defs.items[before], value, ctx->source); + } } } } @@ -8212,7 +8334,14 @@ void cbm_extract_definitions(CBMExtractCtx *ctx) { mod.route_method = "GET"; /* a routable page is reached by navigation */ } } + int mod_idx = ctx->result->defs.count; cbm_defs_push(&ctx->result->defs, a, mod); cbm_extract_definitions_without_module(ctx); + + if (ctx->language == CBM_LANG_JAVASCRIPT || ctx->language == CBM_LANG_TYPESCRIPT || + ctx->language == CBM_LANG_TSX || ctx->language == CBM_LANG_ARKTS) { + /* Same language set as extract_js_vars, which marks the bindings. */ + js_mark_default_export_client(ctx, mod_idx); + } } diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index fcdfc8e09d..7d416923c1 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -394,7 +394,7 @@ static bool process_import_clause(CBMExtractCtx *ctx, TSNode clause, const char const char *sk = ts_node_type(sub); if (strcmp(sk, "identifier") == 0) { char *name = cbm_node_text(a, sub, ctx->source); - CBMImport imp = {.local_name = name, .module_path = path}; + CBMImport imp = {.local_name = name, .module_path = path, .is_default = true}; cbm_imports_push(&ctx->result->imports, a, imp); found = true; } else if (strcmp(sk, "namespace_import") == 0) { diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index d237b2a175..90c8c6d820 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -273,6 +273,8 @@ static void cr_walk_def(cr_ctx_t *c, CBMDefinition *d) { cr_str(c, &d->structural_profile); cr_str(c, &d->body_tokens); cr_str(c, &d->impl_trait); + cr_str(c, &d->http_client); + cr_str(c, &d->http_base_url); } static void cr_walk_call(cr_ctx_t *c, CBMCall *call) { diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index ee54b498ba..f3ab069134 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -57,7 +57,7 @@ src/pipeline/artifact.c 38 src/pipeline/fqn.c 23 src/pipeline/lsp_resolve.h 4 src/pipeline/lsp_surface.c 5 -src/pipeline/pass_calls.c 23 +src/pipeline/pass_calls.c 22 src/pipeline/pass_compile_commands.c 17 src/pipeline/pass_complexity.c 15 src/pipeline/pass_configlink.c 3 diff --git a/src/pipeline/lsp_surface.c b/src/pipeline/lsp_surface.c index 5a8c89f97d..8fb42f6aeb 100644 --- a/src/pipeline/lsp_surface.c +++ b/src/pipeline/lsp_surface.c @@ -125,6 +125,31 @@ static char *surface_file_to_json(const CBMFileResult *result, const CBMLSPDef * } yyjson_mut_obj_add_val(doc, root, "reg", reg); + /* #1916: an axios instance binding's baseURL is consumed by the files + * that import it (their HTTP_CALLS compose base + path), so a changed + * base must change the surface or those importers keep stale edges. + * Written only when the file has such a binding: every other file's + * surface bytes — and so its sha — stay exactly what they were. The + * decoder ignores this key; it feeds the early-cutoff hash only. */ + yyjson_mut_val *http = NULL; + for (int i = 0; result && i < result->defs.count; i++) { + const CBMDefinition *d = &result->defs.items[i]; + if (!d->http_client || !d->qualified_name) { + continue; + } + if (!http) { + http = yyjson_mut_arr(doc); + } + yyjson_mut_val *o = yyjson_mut_obj(doc); + yyjson_mut_obj_add_str(doc, o, "q", d->qualified_name); + yyjson_mut_obj_add_str(doc, o, "c", d->http_client); + add_str_or_null(doc, o, "b", d->http_base_url); + yyjson_mut_arr_add_val(http, o); + } + if (http) { + yyjson_mut_obj_add_val(doc, root, "http", http); + } + char *json = yyjson_mut_write(doc, 0, out_len); yyjson_mut_doc_free(doc); return json; diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954cf..5ada35f9de 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -107,6 +107,16 @@ static char *extract_local_name_from_json(const char *props_json) { return cbm_strndup(start, end - start); } +/* The graph node whose QN is cbm_pipeline_fqn_compute(project, rel_path, name), + * or NULL. The one place this pass computes a transient QN for a lookup. */ +static const cbm_gbuf_node_t *pc_find_by_computed_qn(const cbm_gbuf_t *gbuf, const char *project, + const char *rel_path, const char *name) { + char *qn = cbm_pipeline_fqn_compute(project, rel_path, name); + const cbm_gbuf_node_t *n = qn ? cbm_gbuf_find_by_qn(gbuf, qn) : NULL; + free(qn); + return n; +} + static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count) { @@ -153,9 +163,8 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, } /* Slow path: scan graph buffer IMPORTS edges + parse JSON properties */ - char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel_path, "__file__"); - const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); - free(file_qn); + const cbm_gbuf_node_t *file_node = + pc_find_by_computed_qn(ctx->gbuf, ctx->project_name, rel_path, "__file__"); if (!file_node) { return 0; } @@ -204,6 +213,167 @@ static void free_import_map(const char **keys, const char **vals, int count) { } } +/* ── #1916: axios client-instance calls ─────────────────────────── */ + +/* Copy the plain (unescaped) JSON string value of "key" in props into out. + * False when absent, escaped, or too long — an unreadable base is unknown. */ +static bool pc_json_str_prop(const char *props, const char *key, char *out, size_t out_sz) { + if (!props) { + return false; + } + char pat[CBM_SZ_64]; + snprintf(pat, sizeof(pat), "\"%s\":\"", key); + const char *p = strstr(props, pat); + if (!p) { + return false; + } + p += strlen(pat); + const char *e = strchr(p, '"'); + if (!e) { + return false; + } + size_t len = (size_t)(e - p); + if (len >= out_sz || memchr(p, '\\', len) != NULL) { + return false; + } + memcpy(out, p, len); + out[len] = '\0'; + return true; +} + +/* A node the extractor marked as an axios instance. `base` receives the + * literal baseURL ("" when unknown). */ +static bool pc_node_is_http_client(const cbm_gbuf_node_t *n, char *base, size_t base_sz) { + char client[CBM_SZ_32]; + if (!n || !pc_json_str_prop(n->properties_json, "http_client", client, sizeof(client))) { + return false; + } + if (!pc_json_str_prop(n->properties_json, "http_base_url", base, base_sz)) { + base[0] = '\0'; + } + return true; +} + +/* The module-level Variable `name` declared in file `file_rel`. */ +static const cbm_gbuf_node_t *pc_module_var(const cbm_gbuf_t *gbuf, const char *project, + const char *file_rel, const char *name) { + if (!file_rel) { + return NULL; + } + const cbm_gbuf_node_t *n = pc_find_by_computed_qn(gbuf, project, file_rel, name); + return (n && n->label && strcmp(n->label, "Variable") == 0) ? n : NULL; +} + +/* Resolve the binding `recv` names in the calling file to a client node: + * a same-file declaration first, then its ES import (default import → the + * module's default export; named import → that module's Variable). */ +static bool pc_receiver_client_base(const cbm_gbuf_t *gbuf, const char *project, const char *rel, + const CBMFileResult *result, const char **imp_keys, + const char **imp_vals, int imp_count, const char *recv, + char *base, size_t base_sz) { + if (pc_node_is_http_client(pc_module_var(gbuf, project, rel, recv), base, base_sz)) { + return true; + } + const CBMImport *imp = NULL; + for (int i = 0; result && i < result->imports.count; i++) { + const CBMImport *it = &result->imports.items[i]; + if (it->local_name && strcmp(it->local_name, recv) == 0) { + imp = it; + break; + } + } + if (!imp) { + return false; + } + for (int i = 0; i < imp_count; i++) { + if (!imp_keys[i] || strcmp(imp_keys[i], recv) != 0 || !imp_vals[i]) { + continue; + } + const cbm_gbuf_node_t *t = cbm_gbuf_find_by_qn(gbuf, imp_vals[i]); + if (!t || !t->label) { + return false; + } + if (strcmp(t->label, "Variable") == 0) { + return pc_node_is_http_client(t, base, base_sz); + } + if (strcmp(t->label, "Module") != 0) { + return false; + } + if (imp->is_default) { + return pc_node_is_http_client(t, base, base_sz); + } + return pc_node_is_http_client(pc_module_var(gbuf, project, t->file_path, recv), base, + base_sz); + } + return false; +} + +/* axios instance request methods (the verb after the receiver). */ +static bool pc_is_axios_verb(const char *verb) { + static const char *const verbs[] = {"get", "post", "put", "delete", + "patch", "head", "options", NULL}; + for (int i = 0; verbs[i]; i++) { + if (strcmp(verb, verbs[i]) == 0) { + return true; + } + } + return false; +} + +/* Copy the plain-identifier receiver of `callee` (text before `dot`) into + * recv. False for `this.api.get`, `a().get` and other non-binding receivers. */ +static bool pc_plain_receiver(const char *callee, const char *dot, char *recv, size_t recv_sz) { + size_t rlen = (size_t)(dot - callee); + if (rlen == 0 || rlen >= recv_sz) { + return false; + } + for (size_t i = 0; i < rlen; i++) { + char c = callee[i]; + bool ident = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '_' || c == '$'; + if (!ident) { + return false; + } + } + memcpy(recv, callee, rlen); + recv[rlen] = '\0'; + return true; +} + +bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *project, const char *rel, + const CBMFileResult *result, const char **imp_keys, + const char **imp_vals, int imp_count, const CBMCall *call, + char *out, size_t out_sz) { + const char *callee = call ? call->callee_name : NULL; + const char *url = call ? call->first_string_arg : NULL; + if (!gbuf || !project || !callee || !url || (url[0] != '/' && !strstr(url, "://"))) { + return false; + } + const char *dot = strrchr(callee, '.'); + char recv[CBM_SZ_128]; + if (!dot || !pc_is_axios_verb(dot + SKIP_ONE) || + !pc_plain_receiver(callee, dot, recv, sizeof(recv))) { + return false; + } + char base[CBM_SZ_256]; + if (!pc_receiver_client_base(gbuf, project, rel, result, imp_keys, imp_vals, imp_count, recv, + base, sizeof(base))) { + return false; + } + /* axios combineURLs: an absolute request URL ignores baseURL; otherwise + * the base (trailing '/' trimmed) is joined with the '/'-leading path. */ + if (strstr(url, "://") || base[0] == '\0') { + int n = snprintf(out, out_sz, "%s", url); + return n > 0 && (size_t)n < out_sz; + } + size_t blen = strlen(base); + while (blen > 0 && base[blen - SKIP_ONE] == '/') { + base[--blen] = '\0'; + } + int n = snprintf(out, out_sz, "%s%s", base, url); + return n > 0 && (size_t)n < out_sz; +} + /* Handle a route registration call: create Route node + HANDLES edge. */ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *call, const cbm_gbuf_node_t *source_node, const char *module_qn, @@ -467,18 +637,16 @@ static const cbm_gbuf_node_t *calls_find_source(cbm_pipeline_ctx_t *ctx, const c } } if (!src) { - char *fqn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); - src = cbm_gbuf_find_by_qn(ctx->gbuf, fqn); - free(fqn); + src = pc_find_by_computed_qn(ctx->gbuf, ctx->project_name, rel, "__file__"); } return src; } /* Resolve one call and emit the appropriate edge. Returns 1 if resolved, 0 if not. */ -static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, - const CBMResolvedCallArray *lsp_calls, const char *rel, - const char *module_qn, const char **imp_keys, const char **imp_vals, - int imp_count, CBMLanguage lang) { +static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBMFileResult *result, + const char *rel, const char *module_qn, const char **imp_keys, + const char **imp_vals, int imp_count, CBMLanguage lang) { + const CBMResolvedCallArray *lsp_calls = &result->resolved_calls; const cbm_gbuf_node_t *source_node = calls_find_source(ctx, rel, call->enclosing_func_qn); if (!source_node) { return 0; @@ -527,7 +695,26 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * and not an HTTP pattern, so BOTH the empty-resolution and resolved-QN * service checks below miss it and the call is dropped. Detect it on the * callee_name FIRST so the HTTP_CALLS/ASYNC_CALLS edge is emitted regardless - * (target is a synthesized route node, not the unindexed library). (#523) */ + * (target is a synthesized route node, not the unindexed library). (#523) + * + * First, though: `api.get('/orders')` on an axios.create() instance (#1916). + * The receiver's binding decides, not its spelling — without this the + * verb suffix made it an Express route REGISTRATION below (a CALLS edge + * to a phantom server route), and a receiver spelled `axiosInstance` + * substring-matches the #523 check and loses its baseURL. */ + char client_url[CBM_SZ_512]; + if (cbm_pipeline_http_client_call_url(ctx->gbuf, ctx->project_name, rel, result, imp_keys, + imp_vals, imp_count, call, client_url, + sizeof(client_url))) { + CBMCall routed = *call; + routed.first_string_arg = client_url; + cbm_resolution_t svc_res = {.qualified_name = call->callee_name, + .confidence = PC_SVC_PATTERN_CONF, + .strategy = "http_client_instance", + .candidate_count = 0}; + emit_http_async_edge(ctx, &routed, source_node, NULL, &svc_res, CBM_SVC_HTTP, false); + return SKIP_ONE; + } cbm_svc_kind_t csvc = cbm_service_pattern_match(call->callee_name); if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) { const char *cu = call->first_string_arg; @@ -841,8 +1028,8 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file continue; } total_calls++; - if (resolve_single_call(ctx, call, &result->resolved_calls, rel, module_qn, imp_keys, - imp_vals, imp_count, files[i].language)) { + if (resolve_single_call(ctx, call, result, rel, module_qn, imp_keys, imp_vals, + imp_count, files[i].language)) { resolved++; } else { unresolved++; diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7c7ed68847..804ffba659 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -294,6 +294,8 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types); append_json_string(buf, bufsize, &pos, "route_path", def->route_path); append_json_string(buf, bufsize, &pos, "route_method", def->route_method); + append_json_string(buf, bufsize, &pos, "http_client", def->http_client); + append_json_string(buf, bufsize, &pos, "http_base_url", def->http_base_url); /* MinHash fingerprint — append if present and buffer has room. */ if (def->fingerprint && def->fingerprint_k > 0 && diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f1..c2ee361871 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -522,6 +522,8 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types); append_json_string(buf, bufsize, &pos, "route_path", def->route_path); append_json_string(buf, bufsize, &pos, "route_method", def->route_method); + append_json_string(buf, bufsize, &pos, "http_client", def->http_client); + append_json_string(buf, bufsize, &pos, "http_base_url", def->http_base_url); /* MinHash fingerprint — append if present and buffer has room. * Hex-encoded K=64 uint32 = 512 chars + key/quotes ≈ 520 chars. */ @@ -2966,7 +2968,22 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * pattern, so the resolved-QN service checks below miss it and the call * is dropped. Detect it on the callee_name FIRST so the HTTP_CALLS/ * ASYNC_CALLS edge is emitted regardless (target is a synthesized route - * node, not the unindexed library). Mirrors pass_calls.c. (#523) */ + * node, not the unindexed library). Mirrors pass_calls.c. (#523) + * + * First: a call on an axios.create() instance (#1916) — decided by the + * receiver's binding, before the #523 spelling check and the + * route-registration suffix fallback. MUST match pass_calls.c. */ + char client_url[CBM_SZ_512]; + if (cbm_pipeline_http_client_call_url(rc->main_gbuf, rc->project_name, rel, result, + imp_keys, imp_vals, imp_count, call, client_url, + sizeof(client_url))) { + cbm_resolution_t svc_res = {.qualified_name = call->callee_name, + .confidence = PP_HALF_CONF, + .strategy = "http_client_instance"}; + emit_http_async_service_edge(ws->local_edge_buf, source_node, call, &svc_res, + CBM_SVC_HTTP, client_url); + continue; + } cbm_svc_kind_t csvc = cbm_service_pattern_match(call->callee_name); if (csvc == CBM_SVC_HTTP || csvc == CBM_SVC_ASYNC) { const char *cu = call->first_string_arg; diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 6bc0310dd5..98bc0b7846 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1180,7 +1180,7 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c } str_def_fp += def->fingerprint ? (size_t)def->fingerprint_k * sizeof(uint32_t) : 0; str_def_misc += census_len(def->route_path) + census_len(def->route_method) + - census_len(def->impl_trait); + census_len(def->impl_trait) + census_len(def->http_base_url); } for (int c = 0; c < r->calls.count; c++) { const CBMCall *call = &r->calls.items[c]; diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index 0596eb2388..8634a633e6 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -287,13 +287,25 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g * UNIQUE violation the pre-remap patch would have raised. A resolver that * only LOOKS UP still needs its target resident, which is why the list * mirrors the registry's own membership rule instead of guessing. */ + /* The one property a resolver READS off a proxy: an axios instance + * binding's client + baseURL (#1916). A caller re-resolved alone must + * compose `api.get('/p')` against its unchanged wrapper exactly as a full + * build does. Only the two keys, only on the rare Module/Variable rows + * that carry them; every other proxy stays "{}". Proxies are never + * written back (cbm_delta_patch skips id <= max_db_id). */ sqlite3_stmt *stmt = NULL; - if (sqlite3_prepare_v2(db, - "SELECT id, label, name, qualified_name, file_path FROM nodes" - " WHERE project = ?1 AND label NOT IN" - " ('Macro','Comment','Section','Branch','Commit','Tag')" - " ORDER BY id", - CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + if (sqlite3_prepare_v2( + db, + "SELECT id, label, name, qualified_name, file_path," + " CASE WHEN label IN ('Module','Variable')" + " AND instr(properties, '\"http_client\"') > 0" + " THEN json_object('http_client', json_extract(properties, '$.http_client')," + " 'http_base_url', json_extract(properties, '$.http_base_url')) END" + " FROM nodes" + " WHERE project = ?1 AND label NOT IN" + " ('Macro','Comment','Section','Branch','Commit','Tag')" + " ORDER BY id", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { return -1; } sqlite3_bind_text(stmt, 1, project, CBM_NOT_FOUND, SQLITE_TRANSIENT); @@ -305,9 +317,11 @@ int64_t cbm_delta_preseed(cbm_store_t *store, const char *project, cbm_gbuf_t *g const char *name = (const char *)sqlite3_column_text(stmt, 2); const char *qn = (const char *)sqlite3_column_text(stmt, 3); const char *fp = (const char *)sqlite3_column_text(stmt, 4); + const char *client_props = (const char *)sqlite3_column_text(stmt, 5); /* Pin the gbuf id to the database id: proxies ARE their rows. */ cbm_gbuf_set_next_id(gbuf, id); - int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", 0, 0, "{}"); + int64_t got = cbm_gbuf_upsert_node(gbuf, label, name, qn, fp ? fp : "", 0, 0, + client_props ? client_props : "{}"); if (got != id) { /* A QN collision inside the preseed set would silently split * identity between RAM and disk; the run cannot be trusted. */ diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 78ce486a0f..078ab6e2f2 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -211,6 +211,21 @@ void cbm_pipeline_set_pkgmap(CBMHashTable *map); char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *module_path); +/* #1916: HTTP client-instance calls. When `call` is `.(url)` with + * an HTTP verb suffix and a path/URL first argument, and `recv` is bound — + * in the calling module itself, or via an ES import (named or default) — to a + * binding the extractor marked as an `axios.create(...)` instance + * (`http_client` node property), write the request URL to `out` (the + * instance's literal `http_base_url` joined with a '/'-leading path, the path + * unchanged when the base is unknown or the URL is absolute) and return true. + * Both call resolvers (pass_calls.c, pass_parallel.c) call this before any + * route-registration or registry fallback, so a wrapper client's `api.get` + * is never mistaken for an Express `app.get` route registration. */ +bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *project, const char *rel, + const CBMFileResult *result, const char **imp_keys, + const char **imp_vals, int imp_count, const CBMCall *call, + char *out, size_t out_sz); + /* Resolve an import to its in-graph target node, or NULL if unresolvable. * * Resolution order (first hit wins): diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 1e6506ed1a..3c537421ab 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -181,11 +181,12 @@ TEST(extract_ts_factory_object_methods_issue341) { * * The bound is derived, not tuned. Measured on this source, total_alloc was * 365984 before the scratch arena and is 87456 after, exactly that difference. - * Of the 87456 that remain, 7680 is the defs item array at GROW_ARRAY's - * starting capacity of 32 times sizeof(CBMDefinition) 240, and the other 79776 - * is everything else this file's extraction interns; none of it is traversal - * scratch. So the bound sits above 87456 with room and a factor of four below - * 365984. + * #1916 added two pointers to CBMDefinition (240 -> 256 bytes), so it now + * measures 87968: 8192 is the defs item array at GROW_ARRAY's starting + * capacity of 32 times sizeof(CBMDefinition) 256, and the other 79776 is + * everything else this file's extraction interns; none of it is traversal + * scratch. So the bound sits above 87968 with room and a factor of four below + * the 365984 the scratch stacks cost. * * It is a byte budget, not a proof of lifetime; that is * extract_traversal_stacks_come_from_ctx_scratch_issue2010 in test_mem.c. */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d32ab3538b..63287bb6d1 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5585,6 +5585,210 @@ TEST(pipeline_incremental_parallel_result_cache_alloc_failure_preserves_db_and_r } #endif +/* #1916: one index of the axios-wrapper fixture, reduced to the edge counts + * the test asserts. */ +typedef struct { + int run_rc; + int wrapper_http; /* loadWrapperOrders -HTTP_CALLS-> /orders */ + int wrapper_http_v3; /* same call after the wrapper's base moves to /v3 */ + int wrapper_http_v3_inv; /* ... and then the caller's path moves to /invoices */ + int wrapper_raw_http; /* loadWrapperOrders -HTTP_CALLS-> /orders (uncomposed) */ + int wrapper_route_reg; /* loadWrapperOrders -CALLS-> /orders (phantom route reg) */ + int named_http; /* named import, base with trailing '/' */ + int local_http; /* same-file instance spelled axiosInstance */ + int local_raw_http; /* ... uncomposed (the #523 spelling match) */ + int dynamic_http; /* non-literal baseURL: path kept, never guessed */ + int dynamic_route_reg; /* ... misread as a route registration */ + int direct_http; /* control: axios.get('/direct') */ + int fake_http; /* look-alike factory.create: never a client */ + int provider_handles; /* control: Express app.get stays a registration */ +} AxiosWrapperObs; + +static AxiosWrapperObs observe_axios_wrapper(const char *repo, const char *db) { + AxiosWrapperObs o; + memset(&o, 0xff, sizeof(o)); /* -1 everywhere: "not observed" */ + cbm_pipeline_t *p = cbm_pipeline_new(repo, db, CBM_MODE_FULL); + if (!p) { + return o; + } + o.run_rc = cbm_pipeline_run(p); + const char *proj = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db); + if (s && proj) { + o.wrapper_http = + named_edge_count(s, proj, "HTTP_CALLS", "loadWrapperOrders", "/api/orders"); + o.wrapper_http_v3 = + named_edge_count(s, proj, "HTTP_CALLS", "loadWrapperOrders", "/v3/orders"); + o.wrapper_http_v3_inv = + named_edge_count(s, proj, "HTTP_CALLS", "loadWrapperOrders", "/v3/invoices"); + o.wrapper_raw_http = + named_edge_count(s, proj, "HTTP_CALLS", "loadWrapperOrders", "/orders"); + o.wrapper_route_reg = named_edge_count(s, proj, "CALLS", "loadWrapperOrders", "/orders"); + o.named_http = named_edge_count(s, proj, "HTTP_CALLS", "loadNamedItems", "/v2/items"); + o.local_http = named_edge_count(s, proj, "HTTP_CALLS", "loadLocal", "/local/x"); + o.local_raw_http = named_edge_count(s, proj, "HTTP_CALLS", "loadLocal", "/x"); + o.dynamic_http = named_edge_count(s, proj, "HTTP_CALLS", "loadDynamic", "/dynamic"); + o.dynamic_route_reg = named_edge_count(s, proj, "CALLS", "loadDynamic", "/dynamic"); + o.direct_http = named_edge_count(s, proj, "HTTP_CALLS", "loadDirectOrders", "/direct"); + o.fake_http = named_edge_count(s, proj, "HTTP_CALLS", "loadFake", "/api/fake"); + o.provider_handles = named_edge_count(s, proj, "HANDLES", "provideOrders", "/api/orders"); + } + if (s) { + cbm_store_close(s); + } + cbm_pipeline_free(p); + return o; +} + +/* #1916: `const api = axios.create({ baseURL: '/api' }); export default api` + * in one module and `api.get('/orders')` in another is THE Vue-admin wrapper + * pattern. The call must become HTTP_CALLS to GET /api/orders — before the + * fix the `.get` suffix made it an Express route REGISTRATION (a CALLS edge + * to a phantom server route /orders) and cross-repo matching found nothing. + * 52 fillers put the default run on the parallel resolver; the + * single-thread run is the sequential oracle; a same-DB reindex after the + * wrapper's base changes must converge with the fresh index. */ +TEST(pipeline_axios_wrapper_baseurl_composes_http_calls_issue1916) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_axios_wrapper_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/client.ts", + "import axios from 'axios';\n" + "const api = axios.create({ baseURL: '/api', timeout: 5000 });\n" + "export default api;\n"); + write_temp_file( + tmp, "src/orders.ts", + "import api from './client';\n" + "import axios from 'axios';\n" + "export function loadWrapperOrders(): unknown { return api.get('/orders'); }\n" + "export function loadDirectOrders(): unknown { return axios.get('/direct'); }\n"); + write_temp_file(tmp, "src/named-client.ts", + "import axios from 'axios';\n" + "export const http = axios.create({ baseURL: '/v2/' });\n"); + write_temp_file(tmp, "src/named-caller.ts", + "import { http } from './named-client';\n" + "export function loadNamedItems(): unknown { return http.post('/items'); }\n"); + write_temp_file(tmp, "src/local.ts", + "import axios from 'axios';\n" + "const axiosInstance = axios.create({ baseURL: '/local' });\n" + "export function loadLocal(): unknown { return axiosInstance.get('/x'); }\n"); + write_temp_file(tmp, "src/dynamic-client.ts", + "import axios from 'axios';\n" + "const dynamicApi = axios.create({ baseURL: process.env.API_BASE });\n" + "export default dynamicApi;\n"); + write_temp_file( + tmp, "src/dynamic-caller.ts", + "import dynamicApi from './dynamic-client';\n" + "export function loadDynamic(): unknown { return dynamicApi.get('/dynamic'); }\n"); + write_temp_file(tmp, "src/fake-client.ts", + "const factory = { create: (config: unknown) => config };\n" + "const fakeApi = factory.create({ baseURL: '/api' });\n" + "export default fakeApi;\n"); + write_temp_file(tmp, "src/fake-caller.ts", + "import fakeApi from './fake-client';\n" + "export function loadFake(): unknown { return fakeApi.get('/fake'); }\n"); + write_temp_file(tmp, "src/provider.ts", + "import express from 'express';\n" + "const app = express();\n" + "function provideOrders(): void {}\n" + "app.get('/api/orders', provideOrders);\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/wrapper_pad_%02d.ts", i); + snprintf(body, sizeof(body), "export function wrapperPad%02d(): number { return %d; }\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + + char db_seq[512]; + char db_par[512]; + char db_fresh[512]; + char db_fresh2[512]; + snprintf(db_seq, sizeof(db_seq), "%s/seq.db", tmp); + snprintf(db_par, sizeof(db_par), "%s/par.db", tmp); + snprintf(db_fresh, sizeof(db_fresh), "%s/fresh.db", tmp); + snprintf(db_fresh2, sizeof(db_fresh2), "%s/fresh2.db", tmp); + + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + AxiosWrapperObs seq = observe_axios_wrapper(tmp, db_seq); + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + AxiosWrapperObs par = observe_axios_wrapper(tmp, db_par); + + /* Only the wrapper changes; the importing caller is untouched. */ + write_temp_file(tmp, "src/client.ts", + "import axios from 'axios';\n" + "const api = axios.create({ baseURL: '/v3' });\n" + "export default api;\n"); + AxiosWrapperObs incr = observe_axios_wrapper(tmp, db_par); + AxiosWrapperObs fresh = observe_axios_wrapper(tmp, db_fresh); + /* Then only the caller changes; the (unchanged) wrapper's base must + * still be found when the caller alone is re-resolved. */ + write_temp_file( + tmp, "src/orders.ts", + "import api from './client';\n" + "import axios from 'axios';\n" + "export function loadWrapperOrders(): unknown { return api.get('/invoices'); }\n" + "export function loadDirectOrders(): unknown { return axios.get('/direct'); }\n"); + AxiosWrapperObs incr2 = observe_axios_wrapper(tmp, db_par); + AxiosWrapperObs fresh2 = observe_axios_wrapper(tmp, db_fresh2); + + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + th_rmtree(tmp); + + ASSERT_EQ(seq.run_rc, 0); + ASSERT_EQ(par.run_rc, 0); + /* Controls: direct axios and an Express registration are unchanged, and + * a look-alike factory never becomes a client. */ + ASSERT_EQ(seq.direct_http, 1); + ASSERT_EQ(seq.provider_handles, 1); + ASSERT_EQ(seq.fake_http, 0); + + /* The issue: default-imported wrapper composes base + path. */ + ASSERT_EQ(seq.wrapper_http, 1); + ASSERT_EQ(seq.wrapper_raw_http, 0); + ASSERT_EQ(seq.wrapper_route_reg, 0); + ASSERT_EQ(seq.named_http, 1); + ASSERT_EQ(seq.local_http, 1); + ASSERT_EQ(seq.local_raw_http, 0); + ASSERT_EQ(seq.dynamic_http, 1); + ASSERT_EQ(seq.dynamic_route_reg, 0); + + /* Sequential and parallel resolvers agree edge for edge. */ + ASSERT_MEM_EQ(&seq, &par, sizeof(seq)); + + /* Reindex after the base moved converges with a fresh index. */ + ASSERT_EQ(fresh.run_rc, 0); + ASSERT_EQ(fresh.wrapper_http_v3, 1); + ASSERT_EQ(fresh.wrapper_http, 0); + ASSERT_EQ(incr.run_rc, 0); + ASSERT_MEM_EQ(&incr, &fresh, sizeof(fresh)); + ASSERT_EQ(fresh2.run_rc, 0); + ASSERT_EQ(fresh2.wrapper_http_v3_inv, 1); + ASSERT_EQ(fresh2.wrapper_http_v3, 0); + ASSERT_MEM_EQ(&incr2, &fresh2, sizeof(fresh2)); + PASS(); +} + TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_recv_XXXXXX"); @@ -15115,6 +15319,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_incremental_parallel_result_cache_alloc_failure_preserves_db_and_retries); #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_axios_wrapper_baseurl_composes_http_calls_issue1916); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_keeps_specific_unique_name_member_call); RUN_TEST(pipeline_html_embedded_member_call_stays_unbound);