Merge pull request #840 from DeusData/recover/836-tsjs-suppression

fix(resolver): suppress weak short-name strategies for TS/JS (recovers #836)
This commit is contained in:
Martin Vogel
2026-07-04 15:13:44 +02:00
committed by GitHub
10 changed files with 505 additions and 25 deletions
+3 -1
View File
@@ -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 {
+23
View File
@@ -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)) {
+39 -8
View File
@@ -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];
@@ -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) {
@@ -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];
@@ -450,7 +466,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;
}
}
@@ -474,7 +490,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;
}
}
@@ -502,7 +518,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;
}
}
@@ -522,6 +538,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
@@ -537,7 +568,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;
}
}
@@ -547,7 +578,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;
}
+30 -6
View File
@@ -1765,11 +1765,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;
@@ -1815,7 +1822,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);
}
@@ -2034,6 +2041,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` ->
@@ -2054,7 +2076,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;
}
}
@@ -2066,7 +2088,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;
}
@@ -2097,7 +2119,8 @@ 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++;
}
}
@@ -2105,7 +2128,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
}
_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++;
+8
View File
@@ -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);
+27
View File
@@ -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) {
+90 -6
View File
@@ -3224,11 +3224,12 @@ TEST(extract_perl_method_call_flags_is_method) {
PASS();
}
/* Other languages must be unaffected: a JS method call never sets is_method
* (the flag is Perl-only). */
TEST(extract_non_perl_method_call_not_flagged_is_method) {
CBMFileResult *r =
extract("function run(o){ o.commit(); helper(); }\n", CBM_LANG_JAVASCRIPT, "t", "x.js");
/* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must
* be unaffected: a Go method call never sets is_method. */
TEST(extract_flag_exempt_method_call_not_flagged_is_method) {
CBMFileResult *r = extract("package m\n"
"func run(o Obj) { o.Commit(); helper() }\n",
CBM_LANG_GO, "t", "x.go");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
for (int i = 0; i < r->calls.count; i++) {
@@ -3238,6 +3239,86 @@ TEST(extract_non_perl_method_call_not_flagged_is_method) {
PASS();
}
/* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above).
* A member call x.foo() with a non-this/super receiver is flagged is_method so
* the resolver can suppress a weak short-name match (`re.test()` must not bind a
* project `test`); a bare call is not flagged. */
TEST(extract_ts_member_call_flags_is_method) {
CBMFileResult *r = extract("const re = /^a+$/;\n"
"export function checkFormat(s: string) { return re.test(s); }\n"
"function helper() { return 1; }\n"
"export function run() { return helper(); }\n",
CBM_LANG_TYPESCRIPT, "t", "a.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int member = 0;
int bare = 0;
for (int i = 0; i < r->calls.count; i++) {
const char *cn = r->calls.items[i].callee_name;
if (strcmp(cn, "re.test") == 0) {
member++;
ASSERT_TRUE(r->calls.items[i].is_method);
}
if (strcmp(cn, "helper") == 0) {
bare++;
ASSERT_FALSE(r->calls.items[i].is_method);
}
}
ASSERT_TRUE(member >= 1); /* re.test() flagged */
ASSERT_TRUE(bare >= 1); /* helper() not flagged */
cbm_free_result(r);
PASS();
}
/* this/super receivers keep the enclosing-class target, where a weak
* namespace-proximity match is usually correct — so they are NOT flagged. A
* new_expression has no member receiver and is never flagged either. */
TEST(extract_ts_this_super_receiver_not_flagged) {
CBMFileResult *r = extract("class A extends B {\n"
" m() { this.helper(); super.render(); return new A(); }\n"
" helper() {}\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "b.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
/* Every call here has a self receiver (this/super) or is a new-expression,
* so NONE may be flagged. */
for (int i = 0; i < r->calls.count; i++) {
ASSERT_FALSE(r->calls.items[i].is_method);
}
/* Sanity: the this/super member calls were actually extracted. */
ASSERT_TRUE(has_call(r, "helper")); /* this.helper */
ASSERT_TRUE(has_call(r, "render")); /* super.render */
cbm_free_result(r);
PASS();
}
/* JS dialect behaves like TS (the flag is gated on the language set, not the
* dialect). Replaces the pre-#592 test that asserted JS never flags is_method. */
TEST(extract_js_member_call_flags_is_method) {
CBMFileResult *r =
extract("function run(o){ o.commit(); helper(); }\n", CBM_LANG_JAVASCRIPT, "t", "x.js");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
int member = 0;
int bare = 0;
for (int i = 0; i < r->calls.count; i++) {
const char *cn = r->calls.items[i].callee_name;
if (strcmp(cn, "o.commit") == 0) {
member++;
ASSERT_TRUE(r->calls.items[i].is_method);
}
if (strcmp(cn, "helper") == 0) {
bare++;
ASSERT_FALSE(r->calls.items[i].is_method);
}
}
ASSERT_TRUE(member >= 1); /* o.commit() flagged */
ASSERT_TRUE(bare >= 1); /* helper() not flagged */
cbm_free_result(r);
PASS();
}
/* #668: walk_defs used a fixed `walk_defs_frame_t stack[4096]` — a ~160 KB
* C-stack frame that overflowed small thread stacks (the reporter's crash was in
* the "definitions pass" on a large SQL file), and whose `top < 4096` push guards
@@ -3277,7 +3358,10 @@ SUITE(extraction) {
RUN_TEST(extract_perl_config_string_not_a_callee);
RUN_TEST(extract_perl_builtin_call_is_function_not_method);
RUN_TEST(extract_perl_method_call_flags_is_method);
RUN_TEST(extract_non_perl_method_call_not_flagged_is_method);
RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method);
RUN_TEST(extract_ts_member_call_flags_is_method);
RUN_TEST(extract_ts_this_super_receiver_not_flagged);
RUN_TEST(extract_js_member_call_flags_is_method);
/* R box-module imports + member calls */
RUN_TEST(extract_r_box_use_imports_issue218);
+19 -4
View File
@@ -1006,10 +1006,25 @@ TEST(lrp_ts_s6_inherited_method) {
"import { Base } from './base';\n\n"
"export class Child extends Base {\n extra(): string { return 'child'; }\n}\n\n"
"export function run(c: Child): string { return c.describe(); }\n"}};
/* Uncertain: c.describe() on Child — ts_lsp_cross must see Child extends Base
* (requires TS INHERITS resolution, which has a known extraction bug).
* Assert the correct outcome; RED if TS inheritance extraction bug blocks it. */
ASSERT_TRUE(lrp_assert_calls(f, 2, 1, "ts/S6/inherited_method", 0));
/* RED: c.describe() needs ts_lsp_cross to walk `Child extends Base`. The
* INHERITS edge IS extracted (the probe diagnostic shows INHERITS=1); the
* gap is in ts_lsp_cross's cross-file inheritance RESOLUTION, not extraction.
* Until the receiver-aware guard (#592/#606) landed, this scenario passed via
* a unique_name registry fallback — "describe" is unique in this 2-file
* fixture, so a weak short-name guess happened to be right. In a real repo the
* same guess binds an arbitrary same-named method (the false edges #606
* targets), so the guard now suppresses weak member-call matches with an
* unresolved receiver. c.describe() is the ONLY call in the fixture, so a
* correctly-suppressed run yields exactly zero CALLS.
* Tripwire: assert store opened AND calls == 0 exactly (an infra/DB failure
* must not vacuously pass); flip to ASSERT calls >= 1 once ts_lsp_cross
* resolves inheritance (lsp_ts_*, a strategy the guard keeps). */
LRP_Proj lp;
cbm_store_t *store = lrp_index(&lp, f, 2);
ASSERT_NOT_NULL(store);
int calls = cbm_store_count_edges_by_type(store, lp.project, "CALLS");
lrp_cleanup(&lp, store);
ASSERT_EQ(calls, 0);
PASS();
}
+222
View File
@@ -908,6 +908,226 @@ TEST(pipeline_incremental_preserves_cross_file_calls) {
PASS();
}
/* TS/JS receiver-aware weak-strategy suppression (#592/#606 direction; Perl
* precedent #477). A member call x.foo() whose receiver TYPE the TS-LSP cannot
* resolve (a regex literal `re.test()`) must NOT be bound to a same-named
* project method by a weak short-name strategy — that fabricates a CALLS edge.
* Type-resolved receivers (`c.test()` on a typed SalesforceRestClient) and bare
* local calls must still resolve. < 50 files → sequential path (pass_calls.c).
* RED before the fix: checkFormat->test exists via unique_name/suffix_match. */
static void write_temp_file(const char *dir, const char *name, const char *content);
TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_recv_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
/* The lone project symbol named "test" — a real method. */
write_temp_file(tmp, "src/client.ts",
"export class SalesforceRestClient {\n"
" test(): boolean {\n"
" return true;\n"
" }\n"
"}\n");
/* Regex receiver: `re.test(s)` calls RegExp.prototype.test, NOT the method.
* The TS-LSP cannot bind it to a project symbol → the registry would guess
* "test" by short name (weak). This is the false edge to suppress. */
write_temp_file(tmp, "src/caller.ts",
"const re = /^[a-z]+$/;\n"
"export function checkFormat(s: string): boolean {\n"
" return re.test(s);\n"
"}\n");
/* Typed receiver: `c` is annotated SalesforceRestClient, so the TS-LSP
* resolves c.test() to the method (lsp_ts_method, conf 0.95) BEFORE the
* registry runs — the guard's explicit drop-list keeps every lsp_* edge. */
write_temp_file(tmp, "src/typed.ts",
"import { SalesforceRestClient } from './client';\n"
"export function runTyped(c: SalesforceRestClient): boolean {\n"
" return c.test();\n"
"}\n");
/* Bare local call: same-module resolution, unaffected by the guard. */
write_temp_file(tmp, "src/local.ts",
"function localHelper(): number {\n"
" return 1;\n"
"}\n"
"export function callsLocal(): number {\n"
" return localHelper();\n"
"}\n");
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/tsjs_recv.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);
/* (1) The false edge is suppressed (reproduce-first: RED before the fix). */
ASSERT_FALSE(cross_file_call_exists(s, project, "checkFormat", "test"));
/* (2) The type-resolved receiver call survives (LSP wins before the guard). */
ASSERT_TRUE(cross_file_call_exists(s, project, "runTyped", "test"));
/* (3) The bare local call survives (same-module / lsp_ts_local). */
ASSERT_TRUE(cross_file_call_exists(s, project, "callsLocal", "localHelper"));
/* (4) breadth insurance: the real edges are still emitted. */
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "CALLS"), 2);
cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}
/* Count nodes with the given exact name in the project (e.g. a Route path). */
static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) {
cbm_node_t *ns = NULL;
int n = 0;
cbm_store_find_nodes_by_name(s, project, name, &ns, &n);
if (ns) {
cbm_store_free_nodes(ns, n);
}
return n;
}
/* Parallel-resolver regression for the TS/JS receiver guard (>= 50 files forces
* pass_parallel.c's resolve_file_calls). The guard must not drop a weak member
* match before the service classification runs — it suppresses ONLY the plain
* CALLS fall-through, so every service edge (HTTP_CALLS via the #523 callee
* bypass or emit_service_edge's unconditional detect_url_in_args, Route via the
* ROUTE_REG fall-through, …) is emitted exactly as on main. These callees are
* classified by main's verb-suffix + URL-arg heuristic, NOT by an HTTP library
* name in the callee — a duplicated predicate keyed on the resolved QN lost them
* (axios.get, api.patch on a renamed-axios instance, supertest request(app).get).
* The regex false edge must stay suppressed in parallel too. CBM_WORKERS forces
* >1 worker so the parallel path is taken regardless of the host core count. */
TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_par_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
/* The lone project symbols named get()/patch()/test() — weak short-name
* targets the registry mis-binds the member calls below to. */
write_temp_file(tmp, "src/thing.ts",
"export class ApiThing {\n"
" get(): number {\n"
" return 1;\n"
" }\n"
" patch(): number {\n"
" return 2;\n"
" }\n"
" load(): number {\n"
" return 3;\n"
" }\n"
"}\n"
"export class Other {\n"
" test(): boolean {\n"
" return true;\n"
" }\n"
"}\n");
/* Untyped axios: the HTTP signal is in the callee name, not the resolved QN
* (the registry mis-binds "get" to ApiThing.get). Must yield HTTP_CALLS. */
write_temp_file(tmp, "src/http.ts",
"export function callApi() {\n"
" return axios.get('/api/orders');\n"
"}\n");
/* Renamed axios instance `api`: no HTTP lib id in the callee — classified by
* the .patch verb suffix + URL arg. Must yield HTTP_CALLS (was lost when the
* exemption keyed on the resolved QN only). */
write_temp_file(tmp, "src/http2.ts",
"export function callPatch(): unknown {\n"
" return api.patch('/plans/:id', {});\n"
"}\n");
/* Supertest-style chained receiver `request(app).get('/y')` — untyped, verb
* suffix + URL arg. */
write_temp_file(tmp, "src/supertest.ts",
"export function callSup(app: unknown): unknown {\n"
" return request(app).get('/y');\n"
"}\n");
/* `dev.load('/data')`: `.load` is NOT a route suffix and `dev` is not an HTTP
* lib, so main classifies it via the unconditional detect_url_in_args (URL
* arg) -> HTTP_CALLS, while the weak plain match to ApiThing.load is the false
* edge that must be suppressed. This is exactly the detect_url_in_args path
* the previous (predicate-duplicating) guard skipped by dropping the call
* before emit_service_edge ran — the class of ~399 HTTP_CALLS it lost. */
write_temp_file(tmp, "src/load.ts",
"export function callLoad(dev: unknown): unknown {\n"
" return dev.load('/api/data');\n"
"}\n");
/* Route registration by callee suffix + a '/'-path arg. Must yield a Route. */
write_temp_file(tmp, "src/routes.ts",
"function handler() {}\n"
"export function reg(router: any) {\n"
" router.get('/users', handler);\n"
"}\n");
/* Regex receiver: the false edge that must stay suppressed in parallel too. */
write_temp_file(tmp, "src/re.ts",
"const re = /^[a-z]+$/;\n"
"export function checkFormat(s: string): boolean {\n"
" return re.test(s);\n"
"}\n");
/* Pad past MIN_FILES_FOR_PARALLEL (50) so the parallel resolver runs. */
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/tsjs_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);
/* (1) Genuine HTTP_CALLS survive under the guard (>= 3):
* - axios.get('/api/orders') -> 2 edges (recognized lib #523 callee bypass
* + detect_url_in_args), and
* - dev.load('/api/data') -> 1 edge via detect_url_in_args, which runs
* unconditionally after emit_service_edge's branch even when the plain
* fall-through is suppressed.
* dev.load is the class the predicate-duplicating guard lost: `.load` is not
* a route suffix and `dev` is not an HTTP lib, so it was dropped before
* emit_service_edge ran (RED on that guard: only axios's 2). */
ASSERT_GTE(cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"), 3);
/* (2) The verb-suffix + route-path member calls keep their route
* registrations (edge type CALLS -> a Route node named by the path). These
* classify as route_registration on main, NOT HTTP_CALLS — Option A preserves
* that by construction. Assert the three Route paths survive:
* api.patch('/plans/:id'), request(app).get('/y'), router.get('/users'). */
ASSERT_GTE(count_nodes_named(s, project, "/plans/:id"), 1);
ASSERT_GTE(count_nodes_named(s, project, "/y"), 1);
ASSERT_GTE(count_nodes_named(s, project, "/users"), 1);
/* (3) The false plain-CALLS edges are suppressed in parallel: the regex
* receiver and the dev.load weak match to ApiThing.load. */
ASSERT_FALSE(cross_file_call_exists(s, project, "checkFormat", "test"));
ASSERT_FALSE(cross_file_call_exists(s, project, "callLoad", "load"));
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();
}
/* ── Git history pass tests ─────────────────────────────────────── */
TEST(githistory_is_trackable) {
@@ -6356,6 +6576,8 @@ SUITE(pipeline) {
/* Calls pass */
RUN_TEST(pipeline_calls_resolution);
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);
/* Git history pass */
RUN_TEST(githistory_is_trackable);
RUN_TEST(githistory_compute_coupling);
+44
View File
@@ -742,6 +742,48 @@ TEST(perl_suppress_keeps_high_confidence_and_genuine_calls) {
PASS();
}
TEST(tsjs_suppress_drops_weak_method_matches) {
/* #592/#606: a TS/JS member call whose receiver the LSP could not type, that
* landed via a WEAK short-name strategy, is generic-resolver noise → drop.
* The strategies that actually reach the guards are the registry's
* suffix_match / unique_name and the parallel field_type_hint; "fuzzy" is
* covered defensively (cbm_registry_fuzzy_resolve is not wired into the
* resolvers today) so a future wiring cannot silently reintroduce it. */
ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "suffix_match"));
ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "unique_name"));
ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "field_type_hint"));
ASSERT_TRUE(cbm_tsjs_suppress_weak_method_match(true, true, "fuzzy"));
PASS();
}
TEST(tsjs_suppress_keeps_high_confidence_and_non_methods) {
/* Keep every receiver-/import-aware strategy. Because the PARALLEL resolver
* runs lsp_* strategies through this same guard variable, an explicit
* drop-list must never touch them — asserting the lsp_* keeps here is the
* regression guard for the "kills lsp edges" failure mode. The keep set
* enumerates the resolver's non-weak strategies: registry
* {import_map, import_map_suffix, same_module, qualified_suffix}, parallel
* {callee_suffix, service_pattern}, and lsp_*. */
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "same_module"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "import_map"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "import_map_suffix"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "qualified_suffix"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "callee_suffix"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "service_pattern"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "lsp_ts_method"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "lsp_cross"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, "lsp_ts_local"));
/* A bare call (is_method=false) is a free-function call → never suppressed. */
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, false, "unique_name"));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, false, "suffix_match"));
/* Non-TS/JS languages are never affected. */
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(false, true, "suffix_match"));
/* No match (NULL/empty strategy) → nothing to suppress. */
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, NULL));
ASSERT_FALSE(cbm_tsjs_suppress_weak_method_match(true, true, ""));
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(registry) {
@@ -808,4 +850,6 @@ SUITE(registry) {
RUN_TEST(perl_builtin_set_rejects_project_subs);
RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches);
RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls);
RUN_TEST(tsjs_suppress_drops_weak_method_matches);
RUN_TEST(tsjs_suppress_keeps_high_confidence_and_non_methods);
}