Skip to content
Closed
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: 3 additions & 1 deletion internal/cbm/cbm.h
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ typedef struct {
int loop_depth; // enclosing loop nesting at the call site
int branch_depth; // enclosing branch nesting at the call site
int start_line; // 1-based source line of the call (for def range-match)
bool is_method; // Perl-only: arrow/method call ($obj->m). Default false.
bool is_method; // method/member call with a non-self receiver. Perl:
// arrow/method call ($obj->m). TS/JS/TSX: member call
// x.foo() whose receiver is not this/super. Default false.
} CBMCall;

typedef struct {
Expand Down
23 changes: 23 additions & 0 deletions internal/cbm/extract_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -1892,6 +1892,29 @@ void handle_calls(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec *spec, Walk
strcmp(ts_node_type(node), "method_call_expression") == 0) {
call.is_method = true;
}
// TS/JS/TSX receiver-aware guard (#592/#606 direction; same intent
// as the Perl flag above). Flag a member call x.foo() whose receiver
// is NOT `this`/`super`. When the TS-LSP cannot resolve the receiver
// type, the call-resolution pass suppresses weak short-name matches
// for these (so `re.test()` cannot fabricate an edge to a project
// `test`). this/super receivers stay unflagged — their target is the
// enclosing class, where a namespace-proximity weak match is usually
// right. Bare calls (helper()) and new_expression have no member
// receiver, so they keep is_method=false (struct is zero-init).
if ((ctx->language == CBM_LANG_JAVASCRIPT || ctx->language == CBM_LANG_TYPESCRIPT ||
ctx->language == CBM_LANG_TSX) &&
strcmp(ts_node_type(node), "call_expression") == 0) {
TSNode fn = ts_node_child_by_field_name(node, TS_FIELD("function"));
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "member_expression") == 0) {
TSNode obj = ts_node_child_by_field_name(fn, TS_FIELD("object"));
if (!ts_node_is_null(obj)) {
const char *ok = ts_node_type(obj);
if (strcmp(ok, "this") != 0 && strcmp(ok, "super") != 0) {
call.is_method = true;
}
}
}
}

TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments"));
if (!ts_node_is_null(args)) {
Expand Down
47 changes: 39 additions & 8 deletions src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,21 @@ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const ch

static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target,
const cbm_resolution_t *res, cbm_svc_kind_t svc) {
const cbm_resolution_t *res, cbm_svc_kind_t svc,
bool suppress_plain_calls) {
const char *url_or_topic = call->first_string_arg;
bool is_url = (url_or_topic && url_or_topic[0] != '\0' &&
(url_or_topic[0] == '/' || strstr(url_or_topic, "://") != NULL));
bool is_topic = (url_or_topic && url_or_topic[0] != '\0' && svc == CBM_SVC_ASYNC &&
strlen(url_or_topic) > PAIR_LEN);
if (!is_url && !is_topic) {
/* No URL/topic → this is not a real service call; the svc kind was a
* substring coincidence in the resolved QN (e.g. "SalesforceRestClient"
* matches the "RestClient" HTTP lib). Emit a plain CALLS edge — unless a
* weak TS/JS member-call match should be suppressed (#592/#606). */
if (suppress_plain_calls) {
return;
}
char esc_callee[CBM_SZ_256];
cbm_json_escape(esc_callee, sizeof(esc_callee), call->callee_name);
char props[CBM_SZ_512];
Expand Down Expand Up @@ -368,17 +376,22 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
}

/* Classify a resolved call and emit the appropriate edge. */
/* When suppress_plain_calls is true (a TS/JS/TSX weak short-name member-call
* match, #592/#606), the route/HTTP/ASYNC/CONFIG service classifications below
* still run — only the plain CALLS fall-through is skipped, so a fabricated
* project edge is dropped while every service edge stays main-identical. */
static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target,
const cbm_resolution_t *res, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count) {
const char **imp_keys, const char **imp_vals, int imp_count,
bool suppress_plain_calls) {
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') {
handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count);
return;
}
if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) {
emit_http_async_edge(ctx, call, source, target, res, svc);
emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls);
return;
}
if (svc == CBM_SVC_CONFIG) {
Expand All @@ -393,6 +406,9 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
call);
return;
}
if (suppress_plain_calls) {
return; /* weak TS/JS member-call match with an unresolved receiver (#606) */
}
char esc_c2[CBM_SZ_256];
cbm_json_escape(esc_c2, sizeof(esc_c2), call->callee_name);
char props[CBM_SZ_512];
Expand Down Expand Up @@ -444,7 +460,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
res.strategy = lsp->strategy;
res.candidate_count = 1;
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
imp_vals, imp_count);
imp_vals, imp_count, false);
return SKIP_ONE;
}
}
Expand All @@ -468,7 +484,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "service_pattern",
.candidate_count = 0};
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc);
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc, false);
return SKIP_ONE;
}
}
Expand Down Expand Up @@ -496,7 +512,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
.confidence = PC_SVC_PATTERN_CONF,
.strategy = "service_pattern",
.candidate_count = 0};
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, esvc);
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, esvc, false);
return SKIP_ONE;
}
}
Expand All @@ -516,6 +532,21 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
return 0;
}

/* TS/JS/TSX weak-method suppression (#592/#606). A member call x.foo() only
* reaches the registry when the TS-LSP could not resolve the receiver type
* (the LSP block above already returned for type-resolved calls, including
* the "resolved but target out of gbuf" fall-through). Binding such a call
* by a weak short-name strategy fabricates an edge (`re.test()` -> a project
* `test`). Rather than drop it here — which would also skip the service
* bypasses below and emit_classified_edge's route/HTTP/CONFIG branches —
* defer to emit_classified_edge and suppress ONLY the plain-CALLS
* fall-through, so every service edge stays main-identical. res.strategy may
* be lsp_* here; the helper's explicit drop-list leaves lsp_* untouched. */
bool is_tsjs =
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool tsjs_drop_plain_call =
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);

/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
* name ("requests"), but that library is not in the indexed tree so
Expand All @@ -531,7 +562,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
(u[0] == '/' || strstr(u, "://") != NULL ||
(svc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN));
if (has_url_or_topic) {
emit_http_async_edge(ctx, call, source_node, NULL, &res, svc);
emit_http_async_edge(ctx, call, source_node, NULL, &res, svc, false);
return SKIP_ONE;
}
}
Expand All @@ -541,7 +572,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
return 0;
}
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
imp_count);
imp_count, tsjs_drop_plain_call);
return SKIP_ONE;
}

Expand Down
36 changes: 30 additions & 6 deletions src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -1745,11 +1745,18 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "TRPC_CALLS", props);
}

/* When suppress_plain_calls is true (a TS/JS/TSX weak short-name member-call
* match, #592/#606), every service classification below still runs — only the
* plain CALLS fall-through (emit_normal_calls_edge) is skipped. detect_url_in_args
* and the HTTP/ASYNC/gRPC/GraphQL/tRPC/CONFIG/route branches are unaffected, so
* a verb-suffix HTTP client (api.patch('/x')), broker, or route registration
* keeps its edge; only the fabricated project CALLS edge is dropped. */
static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
const cbm_gbuf_node_t *target, const CBMCall *call,
const cbm_resolution_t *res, const char *module_qn,
const cbm_registry_t *registry, const cbm_gbuf_t *main_gbuf,
const char **imp_keys, const char **imp_vals, int imp_count) {
const char **imp_keys, const char **imp_vals, int imp_count,
bool suppress_plain_calls) {
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
const char *arg = call->first_string_arg;

Expand Down Expand Up @@ -1795,7 +1802,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
emit_trpc_edge(gbuf, source, call, res);
} else if (svc == CBM_SVC_CONFIG) {
emit_config_edge(gbuf, source, target, call, res, arg);
} else {
} else if (!suppress_plain_calls) {
emit_normal_calls_edge(gbuf, source, target, call, res);
}

Expand Down Expand Up @@ -2008,6 +2015,21 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
continue;
}

/* TS/JS/TSX weak-method suppression (#592/#606). The receiver-aware guard
* must NOT drop this call here: doing so would also skip the #523
* callee-name service bypass below, emit_service_edge's route/gRPC/config
* branches, and its unconditional detect_url_in_args (which classifies
* verb-suffix HTTP clients like api.patch('/x')). Instead, defer to the
* emit path and suppress ONLY the plain-CALLS fall-through
* (emit_normal_calls_edge), so every service edge stays main-identical by
* construction. res.strategy may carry an lsp_* value here (LSP-resolved
* calls keep res through this point); the helper's EXPLICIT drop-list
* leaves lsp_ts_method / lsp_cross untouched. See #606 direction. */
bool is_tsjs =
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
bool tsjs_drop_plain_call =
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);

/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
* service signal lives in the callee_name. The registry can mis-resolve
* it to a spurious builtin short-name match (`requests.get` ->
Expand All @@ -2028,7 +2050,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
.strategy = "service_pattern"};
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count);
imp_count, false);
continue;
}
}
Expand All @@ -2040,7 +2062,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
.strategy = "callee_suffix"};
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);
imp_count, false);
}
continue;
}
Expand Down Expand Up @@ -2071,15 +2093,17 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
(psvc == CBM_SVC_ASYNC && strlen(u) > PP_ESC_SPACE));
if (url_or_topic) {
emit_service_edge(ws->local_edge_buf, source_node, NULL, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count);
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
false);
ws->calls_resolved++;
}
}
continue;
}
_rc_t0 = extract_now_ns();
emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn,
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count);
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
tsjs_drop_plain_call);
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
memory_order_relaxed);
ws->calls_resolved++;
Expand Down
8 changes: 8 additions & 0 deletions src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ bool cbm_perl_is_builtin(const char *name);
bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *callee_name,
const char *strategy);

/* Decide whether a resolved TS/JS/TSX member-call edge is weak-strategy noise to
* drop (#592/#606): true only for TS/JS, only for a member call with a
* non-this/super receiver (is_method), and only when the match used a weak
* short-name strategy (suffix_match / unique_name / field_type_hint / fuzzy).
* Explicit drop-list keeps every lsp_* / import / same-module / qualified match.
* Pure; unit-tested in test_registry.c. */
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy);

/* Get the label of a qualified name, or NULL if not found. */
const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn);

Expand Down
27 changes: 27 additions & 0 deletions src/pipeline/registry.c
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,33 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
return true; /* weak short-name match (suffix_match / unique_name / …) → drop */
}

/* TS/JS analogue of the Perl guard above (#592/#606 direction; precedent #477).
* A member call `x.foo()` reaches the weak textual cascade ONLY when the TS-LSP
* could not resolve the receiver type — type-resolved calls win via lsp_*
* strategies before the registry runs. Binding such a call to a project symbol
* by a weak short-name strategy fabricates a CALLS edge (`re.test()` ->
* SalesforceRestClient.test, `date.toISOString()` -> any project toISOString).
* Drop ONLY the weak strategies; keep import/same-module/qualified-tail matches
* and every lsp_* strategy. Uses an EXPLICIT drop-list (not keep-list +
* default-drop) because the parallel resolver runs lsp_* strategies through the
* same guard variable — a default-drop would silently kill lsp_ts_method. Pure
* + side-effect-free so the contract is unit-testable without a full pipeline. */
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy) {
if (!is_tsjs || !is_method || !strategy || !strategy[0]) {
return false;
}
/* Weak short-name strategies that actually reach the call-resolution guards:
* the registry's suffix_match / unique_name and the parallel field_type_hint.
* "fuzzy" is listed as defensive insurance only — cbm_registry_fuzzy_resolve
* is not wired into the sequential/parallel resolvers today, so it never
* reaches this helper, but naming it keeps a future wiring from silently
* reintroducing the noise. Everything else — same_module / import_map /
* import_map_suffix / qualified_suffix / callee_suffix / service_pattern /
* lsp_* — is a receiver- or import-aware match and is KEPT. */
return strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "unique_name") == 0 ||
strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0;
}

/* ── Lifecycle ──────────────────────────────────────────────────── */

cbm_registry_t *cbm_registry_new(void) {
Expand Down
Loading
Loading