diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index b40305af..5665039c 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -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 { diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index a7f4286e..afe2938b 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -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)) { diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 48ad83cc..947c0faf 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -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]; @@ -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; } } @@ -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; } } @@ -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; } } @@ -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 @@ -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; } } @@ -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; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 68d7d604..ad6ee10c 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -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; @@ -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); } @@ -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` -> @@ -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; } } @@ -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; } @@ -2071,7 +2093,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++; } } @@ -2079,7 +2102,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++; diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index ba50c4d0..535c717f 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -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); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 3724c334..50fb881c 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -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) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e78265b5..3dd54acb 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3224,16 +3224,97 @@ 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) { +/* 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++) { + ASSERT_FALSE(r->calls.items[i].is_method); + } + cbm_free_result(r); + 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++) { - ASSERT_FALSE(r->calls.items[i].is_method); + 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(); } @@ -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); diff --git a/tests/test_lsp_resolution_probe.c b/tests/test_lsp_resolution_probe.c index 31bec068..cac76c41 100644 --- a/tests/test_lsp_resolution_probe.c +++ b/tests/test_lsp_resolution_probe.c @@ -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(); } diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 1926fc8f..ff503e56 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -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); diff --git a/tests/test_registry.c b/tests/test_registry.c index 1a511df0..a79aaffd 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -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); }