Skip to content

Commit 26689e7

Browse files
fix(resolver): suppress weak short-name matches for TS/JS method calls
A member call `x.foo()` reaches the registry's 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). On a 6k-file monorepo this produced ~21.6k false edges (~12.6% of CALLS). Add cbm_tsjs_suppress_weak_method_match: for a TS/JS/TSX member call flagged is_method, the weak strategies (suffix_match, unique_name, field_type_hint, fuzzy) are noise. It uses an EXPLICIT drop-list, not keep-list + default-drop, because the resolver runs lsp_* through the same code path — a default-drop would silently kill lsp_ts_method. Gated on the file language so no other language is affected. The suppression is applied at the PLAIN-CALLS emission point, not before it: emit_classified_edge (sequential) and emit_service_edge (parallel) take a suppress_plain_calls flag and skip ONLY the final plain CALLS fall-through (and emit_http_async_edge's no-URL fall-through). Every service classification that runs first — the #523 callee-name HTTP/ASYNC bypass, route registration, gRPC/GraphQL/tRPC/CONFIG, and emit_service_edge's unconditional detect_url_in_args — is therefore byte-identical to main by construction. This replaces an earlier attempt that dropped the call at a language guard and tried to re-derive "is this a service edge?" with a predicate: that predicate drifted from the emit path's classification and lost ~399 HTTP_CALLS (verb-suffix clients like api.patch / page.goto / request(app).get, whose HTTP signal is not a library name in the callee) plus ~63 CONFIGURES on the monorepo. Suppressing at the emit point cannot drift. Tests: unit tests pin the keep/drop split (including lsp_* -> keep). A reproduce-first sequential E2E fixture asserts the regex-receiver false edge is gone while a typed-receiver call (lsp_ts_method) and a bare local call survive. A >=50-file parallel fixture (CBM_WORKERS forces the parallel resolver) asserts that axios.get and dev.load('/api/data') keep their HTTP_CALLS (the latter via detect_url_in_args — the class the old predicate lost), that api.patch, request(app).get and router.get keep their Route registrations, and that the regex and dev.load weak plain-CALLS edges stay suppressed. The ts/S6 inherited-method probe, which used to pass via a fragile unique_name fallback, now asserts exactly zero CALLS with a store-opened guard (the INHERITS edge IS extracted; the gap is ts_lsp_cross's cross-file resolution, which the guard keeps once wired). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Hitoshi Okazaki <okazaki.hitoshi@tecnos.co.jp>
1 parent 60d0855 commit 26689e7

7 files changed

Lines changed: 389 additions & 18 deletions

File tree

src/pipeline/pass_calls.c

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -327,13 +327,21 @@ static void calls_emit_edge(cbm_gbuf_t *gbuf, int64_t src, int64_t tgt, const ch
327327

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

370378
/* Classify a resolved call and emit the appropriate edge. */
379+
/* When suppress_plain_calls is true (a TS/JS/TSX weak short-name member-call
380+
* match, #592/#606), the route/HTTP/ASYNC/CONFIG service classifications below
381+
* still run — only the plain CALLS fall-through is skipped, so a fabricated
382+
* project edge is dropped while every service edge stays main-identical. */
371383
static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
372384
const cbm_gbuf_node_t *source, const cbm_gbuf_node_t *target,
373385
const cbm_resolution_t *res, const char *module_qn,
374-
const char **imp_keys, const char **imp_vals, int imp_count) {
386+
const char **imp_keys, const char **imp_vals, int imp_count,
387+
bool suppress_plain_calls) {
375388
cbm_svc_kind_t svc = cbm_service_pattern_match(res->qualified_name);
376389
if (svc == CBM_SVC_ROUTE_REG && call->first_string_arg && call->first_string_arg[0] == '/') {
377390
handle_route_registration(ctx, call, source, module_qn, imp_keys, imp_vals, imp_count);
378391
return;
379392
}
380393
if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) {
381-
emit_http_async_edge(ctx, call, source, target, res, svc);
394+
emit_http_async_edge(ctx, call, source, target, res, svc, suppress_plain_calls);
382395
return;
383396
}
384397
if (svc == CBM_SVC_CONFIG) {
@@ -393,6 +406,9 @@ static void emit_classified_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
393406
call);
394407
return;
395408
}
409+
if (suppress_plain_calls) {
410+
return; /* weak TS/JS member-call match with an unresolved receiver (#606) */
411+
}
396412
char esc_c2[CBM_SZ_256];
397413
cbm_json_escape(esc_c2, sizeof(esc_c2), call->callee_name);
398414
char props[CBM_SZ_512];
@@ -444,7 +460,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
444460
res.strategy = lsp->strategy;
445461
res.candidate_count = 1;
446462
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys,
447-
imp_vals, imp_count);
463+
imp_vals, imp_count, false);
448464
return SKIP_ONE;
449465
}
450466
}
@@ -468,7 +484,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
468484
.confidence = PC_SVC_PATTERN_CONF,
469485
.strategy = "service_pattern",
470486
.candidate_count = 0};
471-
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc);
487+
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, csvc, false);
472488
return SKIP_ONE;
473489
}
474490
}
@@ -496,7 +512,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
496512
.confidence = PC_SVC_PATTERN_CONF,
497513
.strategy = "service_pattern",
498514
.candidate_count = 0};
499-
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, esvc);
515+
emit_http_async_edge(ctx, call, source_node, NULL, &svc_res, esvc, false);
500516
return SKIP_ONE;
501517
}
502518
}
@@ -516,6 +532,21 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
516532
return 0;
517533
}
518534

535+
/* TS/JS/TSX weak-method suppression (#592/#606). A member call x.foo() only
536+
* reaches the registry when the TS-LSP could not resolve the receiver type
537+
* (the LSP block above already returned for type-resolved calls, including
538+
* the "resolved but target out of gbuf" fall-through). Binding such a call
539+
* by a weak short-name strategy fabricates an edge (`re.test()` -> a project
540+
* `test`). Rather than drop it here — which would also skip the service
541+
* bypasses below and emit_classified_edge's route/HTTP/CONFIG branches —
542+
* defer to emit_classified_edge and suppress ONLY the plain-CALLS
543+
* fall-through, so every service edge stays main-identical. res.strategy may
544+
* be lsp_* here; the helper's explicit drop-list leaves lsp_* untouched. */
545+
bool is_tsjs =
546+
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
547+
bool tsjs_drop_plain_call =
548+
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
549+
519550
/* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g.
520551
* `requests.get("/api/orders/{id}")`) resolve to a QN containing the library
521552
* name ("requests"), but that library is not in the indexed tree so
@@ -531,7 +562,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
531562
(u[0] == '/' || strstr(u, "://") != NULL ||
532563
(svc == CBM_SVC_ASYNC && strlen(u) > PAIR_LEN));
533564
if (has_url_or_topic) {
534-
emit_http_async_edge(ctx, call, source_node, NULL, &res, svc);
565+
emit_http_async_edge(ctx, call, source_node, NULL, &res, svc, false);
535566
return SKIP_ONE;
536567
}
537568
}
@@ -541,7 +572,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
541572
return 0;
542573
}
543574
emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, imp_vals,
544-
imp_count);
575+
imp_count, tsjs_drop_plain_call);
545576
return SKIP_ONE;
546577
}
547578

src/pipeline/pass_parallel.c

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,11 +1745,18 @@ static void emit_trpc_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, cons
17451745
cbm_gbuf_insert_edge(gbuf, source->id, route_id, "TRPC_CALLS", props);
17461746
}
17471747

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

@@ -1795,7 +1802,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
17951802
emit_trpc_edge(gbuf, source, call, res);
17961803
} else if (svc == CBM_SVC_CONFIG) {
17971804
emit_config_edge(gbuf, source, target, call, res, arg);
1798-
} else {
1805+
} else if (!suppress_plain_calls) {
17991806
emit_normal_calls_edge(gbuf, source, target, call, res);
18001807
}
18011808

@@ -2008,6 +2015,21 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
20082015
continue;
20092016
}
20102017

2018+
/* TS/JS/TSX weak-method suppression (#592/#606). The receiver-aware guard
2019+
* must NOT drop this call here: doing so would also skip the #523
2020+
* callee-name service bypass below, emit_service_edge's route/gRPC/config
2021+
* branches, and its unconditional detect_url_in_args (which classifies
2022+
* verb-suffix HTTP clients like api.patch('/x')). Instead, defer to the
2023+
* emit path and suppress ONLY the plain-CALLS fall-through
2024+
* (emit_normal_calls_edge), so every service edge stays main-identical by
2025+
* construction. res.strategy may carry an lsp_* value here (LSP-resolved
2026+
* calls keep res through this point); the helper's EXPLICIT drop-list
2027+
* leaves lsp_ts_method / lsp_cross untouched. See #606 direction. */
2028+
bool is_tsjs =
2029+
lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX;
2030+
bool tsjs_drop_plain_call =
2031+
cbm_tsjs_suppress_weak_method_match(is_tsjs, call->is_method, res.strategy);
2032+
20112033
/* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the
20122034
* service signal lives in the callee_name. The registry can mis-resolve
20132035
* it to a spurious builtin short-name match (`requests.get` ->
@@ -2028,7 +2050,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
20282050
.strategy = "service_pattern"};
20292051
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &svc_res,
20302052
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
2031-
imp_count);
2053+
imp_count, false);
20322054
continue;
20332055
}
20342056
}
@@ -2040,7 +2062,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
20402062
.strategy = "callee_suffix"};
20412063
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
20422064
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
2043-
imp_count);
2065+
imp_count, false);
20442066
}
20452067
continue;
20462068
}
@@ -2071,15 +2093,17 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
20712093
(psvc == CBM_SVC_ASYNC && strlen(u) > PP_ESC_SPACE));
20722094
if (url_or_topic) {
20732095
emit_service_edge(ws->local_edge_buf, source_node, NULL, call, &res, module_qn,
2074-
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count);
2096+
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
2097+
false);
20752098
ws->calls_resolved++;
20762099
}
20772100
}
20782101
continue;
20792102
}
20802103
_rc_t0 = extract_now_ns();
20812104
emit_service_edge(ws->local_edge_buf, source_node, target_node, call, &res, module_qn,
2082-
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count);
2105+
rc->registry, rc->main_gbuf, imp_keys, imp_vals, imp_count,
2106+
tsjs_drop_plain_call);
20832107
atomic_fetch_add_explicit(&rc->time_ns_rc_emit, extract_now_ns() - _rc_t0,
20842108
memory_order_relaxed);
20852109
ws->calls_resolved++;

src/pipeline/pipeline.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,14 @@ bool cbm_perl_is_builtin(const char *name);
223223
bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *callee_name,
224224
const char *strategy);
225225

226+
/* Decide whether a resolved TS/JS/TSX member-call edge is weak-strategy noise to
227+
* drop (#592/#606): true only for TS/JS, only for a member call with a
228+
* non-this/super receiver (is_method), and only when the match used a weak
229+
* short-name strategy (suffix_match / unique_name / field_type_hint / fuzzy).
230+
* Explicit drop-list keeps every lsp_* / import / same-module / qualified match.
231+
* Pure; unit-tested in test_registry.c. */
232+
bool cbm_tsjs_suppress_weak_method_match(bool is_tsjs, bool is_method, const char *strategy);
233+
226234
/* Get the label of a qualified name, or NULL if not found. */
227235
const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn);
228236

src/pipeline/registry.c

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,33 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c
423423
return true; /* weak short-name match (suffix_match / unique_name / …) → drop */
424424
}
425425

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

428455
cbm_registry_t *cbm_registry_new(void) {

tests/test_lsp_resolution_probe.c

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,10 +1006,25 @@ TEST(lrp_ts_s6_inherited_method) {
10061006
"import { Base } from './base';\n\n"
10071007
"export class Child extends Base {\n extra(): string { return 'child'; }\n}\n\n"
10081008
"export function run(c: Child): string { return c.describe(); }\n"}};
1009-
/* Uncertain: c.describe() on Child — ts_lsp_cross must see Child extends Base
1010-
* (requires TS INHERITS resolution, which has a known extraction bug).
1011-
* Assert the correct outcome; RED if TS inheritance extraction bug blocks it. */
1012-
ASSERT_TRUE(lrp_assert_calls(f, 2, 1, "ts/S6/inherited_method", 0));
1009+
/* RED: c.describe() needs ts_lsp_cross to walk `Child extends Base`. The
1010+
* INHERITS edge IS extracted (the probe diagnostic shows INHERITS=1); the
1011+
* gap is in ts_lsp_cross's cross-file inheritance RESOLUTION, not extraction.
1012+
* Until the receiver-aware guard (#592/#606) landed, this scenario passed via
1013+
* a unique_name registry fallback — "describe" is unique in this 2-file
1014+
* fixture, so a weak short-name guess happened to be right. In a real repo the
1015+
* same guess binds an arbitrary same-named method (the false edges #606
1016+
* targets), so the guard now suppresses weak member-call matches with an
1017+
* unresolved receiver. c.describe() is the ONLY call in the fixture, so a
1018+
* correctly-suppressed run yields exactly zero CALLS.
1019+
* Tripwire: assert store opened AND calls == 0 exactly (an infra/DB failure
1020+
* must not vacuously pass); flip to ASSERT calls >= 1 once ts_lsp_cross
1021+
* resolves inheritance (lsp_ts_*, a strategy the guard keeps). */
1022+
LRP_Proj lp;
1023+
cbm_store_t *store = lrp_index(&lp, f, 2);
1024+
ASSERT_NOT_NULL(store);
1025+
int calls = cbm_store_count_edges_by_type(store, lp.project, "CALLS");
1026+
lrp_cleanup(&lp, store);
1027+
ASSERT_EQ(calls, 0);
10131028
PASS();
10141029
}
10151030

0 commit comments

Comments
 (0)