diff --git a/internal/cbm/lsp/ts_lsp.c b/internal/cbm/lsp/ts_lsp.c index 7fe5055fd..967d06762 100644 --- a/internal/cbm/lsp/ts_lsp.c +++ b/internal/cbm/lsp/ts_lsp.c @@ -1998,6 +1998,22 @@ static const CBMType *ts_new_bare_class_type(TSLSPContext *ctx, const char *cnam return cbm_type_named(ctx->arena, local_qn); } +/* Type of a `new_expression`: a bare constructor name follows TS scoping + * (ts_new_bare_class_type), a dotted one is taken as spelled. NULL when the + * node carries no constructor. Shared by expression evaluation and by the + * field-initializer inference in ast_sweep_shapes, so `const t = new C()` + * and `private t = new C()` type the instance identically. */ +static const CBMType *ts_new_expression_type(TSLSPContext *ctx, TSNode node) { + TSNode ctor = ts_node_child_by_field_name(node, "constructor", TS_LSP_FIELD_LEN("constructor")); + if (ts_node_is_null(ctor)) + return NULL; + char *cname = node_text(ctx, ctor); + if (!cname) + return NULL; + return strchr(cname, '.') == NULL ? ts_new_bare_class_type(ctx, cname) + : cbm_type_named(ctx->arena, cname); +} + static const CBMRegisteredFunc *ts_lookup_namespace_call(TSLSPContext *ctx, const char *object_name, const char *method_name, TSNode args, bool *out_is_import) { @@ -2223,15 +2239,9 @@ const CBMType *ts_eval_expr_type(TSLSPContext *ctx, TSNode node) { result = lookup_member_type(ctx, recv, pname); } } else if (strcmp(kind, "new_expression") == 0) { - TSNode ctor = - ts_node_child_by_field_name(node, "constructor", TS_LSP_FIELD_LEN("constructor")); - if (!ts_node_is_null(ctor)) { - char *cname = node_text(ctx, ctor); - if (cname) { - result = strchr(cname, '.') == NULL ? ts_new_bare_class_type(ctx, cname) - : cbm_type_named(ctx->arena, cname); - } - } + const CBMType *nt = ts_new_expression_type(ctx, node); + if (nt) + result = nt; } else if (strcmp(kind, "call_expression") == 0) { TSNode fn = ts_node_child_by_field_name(node, "function", TS_LSP_FIELD_LEN("function")); if (!ts_node_is_null(fn)) { @@ -5357,6 +5367,76 @@ static const CBMType *interface_method_signature_from_ast(TSLSPContext *ctx, TSN return cbm_type_func(ctx->arena, NULL, param_count ? param_types : NULL, return_types); } +/* Parsed type of a `type_annotation` field (`: T`), or unknown. */ +static const CBMType *ts_annotation_field_type(TSLSPContext *ctx, TSNode owner) { + TSNode tann = ts_node_child_by_field_name(owner, "type", TS_LSP_FIELD_LEN("type")); + if (ts_node_is_null(tann)) + return cbm_type_unknown(); + TSNode tn = + strcmp(ts_node_type(tann), "type_annotation") == 0 ? ts_node_named_child(tann, 0) : tann; + return ts_node_is_null(tn) ? cbm_type_unknown() : ts_parse_type_node(ctx, tn); +} + +/* Declared type of a class field (`public_field_definition`) or interface + * property: the annotation when present, else, for an unannotated field + * initialised with `new C()`, the type of that instance (#514). No other + * initializer is inferred: `new` names its type exactly, arbitrary + * expressions do not. */ +static const CBMType *ts_field_decl_type(TSLSPContext *ctx, TSNode m) { + if (!ts_node_is_null(ts_node_child_by_field_name(m, "type", TS_LSP_FIELD_LEN("type")))) + return ts_annotation_field_type(ctx, m); + TSNode val = ts_node_child_by_field_name(m, "value", TS_LSP_FIELD_LEN("value")); + if (ts_node_is_null(val) || strcmp(ts_node_type(val), "new_expression") != 0) + return cbm_type_unknown(); + const CBMType *nt = ts_new_expression_type(ctx, val); + return nt ? nt : cbm_type_unknown(); +} + +/* A constructor parameter is a TS parameter property, i.e. also declares a + * class field, iff it carries an accessibility modifier, `readonly` or + * `override`. */ +static bool ts_param_is_property(TSNode p) { + uint32_t pc = ts_node_child_count(p); + for (uint32_t ci = 0; ci < pc; ci++) { + const char *ck = ts_node_type(ts_node_child(p, ci)); + if (strcmp(ck, "accessibility_modifier") == 0 || strcmp(ck, "readonly") == 0 || + strcmp(ck, "override_modifier") == 0) + return true; + } + return false; +} + +/* Append the parameter properties of a `constructor` method_definition to + * the field arrays (#514): `constructor(private readonly svc: Svc)` declares + * field `svc: Svc`, the dominant NestJS injection shape. Any other member is + * ignored. Returns the new field count. */ +static int ts_collect_param_properties(TSLSPContext *ctx, TSNode m, const char **names, + const CBMType **types, int count, int cap) { + TSNode mname = ts_node_child_by_field_name(m, "name", TS_LSP_FIELD_LEN("name")); + char *mnm = ts_node_is_null(mname) ? NULL : node_text(ctx, mname); + if (!mnm || strcmp(mnm, "constructor") != 0) + return count; + TSNode params = ts_node_child_by_field_name(m, "parameters", TS_LSP_FIELD_LEN("parameters")); + uint32_t pnc = ts_node_is_null(params) ? 0 : ts_node_named_child_count(params); + for (uint32_t pi = 0; pi < pnc && count < cap; pi++) { + TSNode p = ts_node_named_child(params, pi); + const char *pk = ts_node_type(p); + if ((strcmp(pk, "required_parameter") != 0 && strcmp(pk, "optional_parameter") != 0) || + !ts_param_is_property(p)) + continue; + TSNode pat = ts_node_child_by_field_name(p, "pattern", TS_LSP_FIELD_LEN("pattern")); + if (ts_node_is_null(pat) || strcmp(ts_node_type(pat), "identifier") != 0) + continue; + char *pnm = node_text(ctx, pat); + if (!pnm) + continue; + names[count] = pnm; + types[count] = ts_annotation_field_type(ctx, p); + count++; + } + return count; +} + // AST sweep: walk class/interface bodies to collect field names+types and refine // embedded_types / type_param_names. static void ast_sweep_shapes(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) { @@ -5469,24 +5549,24 @@ static void ast_sweep_shapes(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *re if (strcmp(mk, "public_field_definition") == 0 || strcmp(mk, "property_signature") == 0) { TSNode fname = ts_node_child_by_field_name(m, "name", TS_LSP_FIELD_LEN("name")); - TSNode ftype = ts_node_child_by_field_name(m, "type", TS_LSP_FIELD_LEN("type")); if (ts_node_is_null(fname)) continue; char *fnm = node_text(ctx, fname); if (!fnm) continue; - const CBMType *ft = cbm_type_unknown(); - if (!ts_node_is_null(ftype)) { - TSNode tch = ts_node_named_child(ftype, 0); - if (!ts_node_is_null(tch)) - ft = ts_parse_type_node(ctx, tch); - } field_names[field_count] = fnm; - field_types[field_count] = ft; + field_types[field_count] = ts_field_decl_type(ctx, m); field_count++; continue; } + // Constructor parameter properties are class fields too (#514). + if (!is_interface && strcmp(mk, "method_definition") == 0) { + field_count = + ts_collect_param_properties(ctx, m, field_names, field_types, field_count, 63); + continue; + } + // Interface method signature: `methodName(params): ReturnType;` // Or class method declaration in `.d.ts` ambient mode. if (is_interface && (strcmp(mk, "method_signature") == 0)) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ec0fff931..f9f73746d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -6343,6 +6343,254 @@ TEST(pipeline_ts_crossfile_new_instance_method_call_issue1354) { PASS(); } +/* A query failure is distinct from a valid zero-edge result. */ +static int ts514_pipeline_count(cbm_store_t *store, const char *sql, const char **values, + int value_count) { + sqlite3_stmt *stmt = NULL; + sqlite3 *db = cbm_store_get_db(store); + if (!db || sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) { + sqlite3_finalize(stmt); + return -1; + } + bool bound = sqlite3_bind_parameter_count(stmt) == value_count; + for (int i = 0; bound && i < value_count; i++) + bound = values[i] && + sqlite3_bind_text(stmt, i + 1, values[i], -1, SQLITE_TRANSIENT) == SQLITE_OK; + int count = -1; + if (bound && sqlite3_step(stmt) == SQLITE_ROW) { + count = sqlite3_column_int(stmt, 0); + if (sqlite3_step(stmt) != SQLITE_DONE) + count = -1; + } + if (sqlite3_finalize(stmt) != SQLITE_OK) + count = -1; + return count; +} + +/* #514: constructor injection and new-initialized fields resolve through both + * drivers. Same-named methods make wrong-owner and self-loop claims meaningful. + * Every negative has a real extracted call, source and target, plus successful + * queries and typed positive controls in the same run. */ +TEST(pipeline_ts_param_property_injection_issue514) { + static const char *paths[] = {"cats/service.ts", "cats/controller.ts", "cats/reporter.ts"}; + static const char *sources[] = { + "export class BaseService { findAll(): string[] { return []; } }\n" + "export class CatService extends BaseService { findAll(): string[] { return ['cat']; } }\n", + "import { CatService } from './service';\n" + "export class CatController {\n" + " constructor(private readonly catService: CatService, plain: CatService) {}\n" + " findAll() { return this.catService.findAll(); }\n" + " direct(control: CatService) { return control.findAll(); }\n" + " viaPlain() { return this.plain.findAll(); }\n" + "}\n", + "import { CatService, BaseService } from './service';\n" + "function makeService(): CatService { return new CatService(); }\n" + "export class CatReporter {\n" + " private fresh = new CatService();\n" + " private annotated: BaseService = new CatService();\n" + " private factory = makeService();\n" + " constructor(protected cats: CatService) {}\n" + " reportInjected() { return this.cats.findAll(); }\n" + " reportLocal() { return this.fresh.findAll(); }\n" + " reportAnnotated() { return this.annotated.findAll(); }\n" + " reportFactory() { return this.factory.findAll(); }\n" + "}\n", + }; + static const struct { + int file; + const char *symbol; + const char *expression; + const char *callee; + const char *target; + int want; + } cases[] = { + {1, "CatController.direct", "control.findAll()", "control.findAll", "CatService.findAll", + 1}, + {1, "CatController.findAll", "this.catService.findAll()", "this.catService.findAll", + "CatService.findAll", 1}, + {2, "CatReporter.reportInjected", "this.cats.findAll()", "this.cats.findAll", + "CatService.findAll", 1}, + {2, "CatReporter.reportLocal", "this.fresh.findAll()", "this.fresh.findAll", + "CatService.findAll", 1}, + {2, "CatReporter.reportAnnotated", "this.annotated.findAll()", "this.annotated.findAll", + "BaseService.findAll", 1}, + {1, "CatController.viaPlain", "this.plain.findAll()", "this.plain.findAll", + "CatService.findAll", 0}, + {2, "CatReporter.reportFactory", "this.factory.findAll()", "this.factory.findAll", + "CatService.findAll", 0}, + }; + enum { NCASES = (int)(sizeof(cases) / sizeof(cases[0])) }; + static const char node_sql[] = "SELECT count(*) FROM nodes WHERE project=?1 AND " + "qualified_name=?2 AND file_path=?3 AND label='Method'"; + static const char edge_sql[] = + "SELECT count(*) FROM edges e JOIN nodes s ON s.id=e.source_id JOIN nodes t ON " + "t.id=e.target_id " + "WHERE e.project=?1 AND e.type='CALLS' AND s.qualified_name=?2 AND s.file_path=?3 " + "AND t.qualified_name=?4 AND t.file_path=?5 " + "AND (?6='' OR json_extract(e.properties,'$.strategy')=?6)"; + + CBMFileResult *fr[3] = {0}; + bool extraction_ok = true; + for (int i = 0; i < 3; i++) { + fr[i] = cbm_extract_file(sources[i], (int)strlen(sources[i]), CBM_LANG_TYPESCRIPT, + "ts514_probe", paths[i], 0, NULL, NULL); + extraction_ok = extraction_ok && fr[i] && !fr[i]->has_error; + } + int occurrences[NCASES] = {0}; + if (extraction_ok) { + for (int i = 0; i < NCASES; i++) { + int file = cases[i].file; + const char *site = strstr(sources[file], cases[i].expression); + char *caller = cbm_pipeline_fqn_compute("ts514_probe", paths[file], cases[i].symbol); + for (int c = 0; site && caller && c < fr[file]->calls.count; c++) { + const CBMCall *call = &fr[file]->calls.items[c]; + uint32_t start = (uint32_t)(site - sources[file]); + if (call->enclosing_func_qn && strcmp(call->enclosing_func_qn, caller) == 0 && + call->callee_name && strcmp(call->callee_name, cases[i].callee) == 0 && + call->site_start_byte == start && + call->site_end_byte == start + strlen(cases[i].expression) && + call->start_line > 0) + occurrences[i]++; + } + free(caller); + } + } + for (int i = 0; i < 3; i++) + cbm_free_result(fr[i]); + + const char *old_workers = getenv("CBM_WORKERS"); + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + char *saved_single = old_single ? strdup(old_single) : NULL; + if ((old_workers && !saved_workers) || (old_single && !saved_single)) { + free(saved_workers); + free(saved_single); + FAIL("save environment"); + } + char tmp[256] = "/tmp/cbm_ts_514_XXXXXX"; + if (!cbm_mkdtemp(tmp)) { + free(saved_workers); + free(saved_single); + FAIL("tmpdir"); + } + for (int i = 0; i < 3; i++) + write_temp_file(tmp, paths[i], sources[i]); + for (int i = 0; i < 52; i++) { + char name[64], body[128]; + snprintf(name, sizeof(name), "cats/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number { return %d; }\n", i, i); + write_temp_file(tmp, name, body); + } + int run_rc[2] = {-1, -1}, files[2] = {-1, -1}; + int edges[2][NCASES], source_nodes[2][NCASES], target_nodes[2][NCASES]; + int unresolved_calls[2][2] = {{-1, -1}, {-1, -1}}; + int self_loop[2] = {-1, -1}, annotation_wrong[2] = {-1, -1}; + bool configured[2] = {false, false}, opened[2] = {false, false}; + for (int mode = 0; mode < 2; mode++) { + for (int i = 0; i < NCASES; i++) + edges[mode][i] = source_nodes[mode][i] = target_nodes[mode][i] = -1; + cbm_setenv("CBM_WORKERS", "4", 1); + if (mode == 0) + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + else + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + const char *workers = getenv("CBM_WORKERS"); + const char *single = getenv("CBM_INDEX_SINGLE_THREAD"); + configured[mode] = workers && strcmp(workers, "4") == 0 && + (mode == 0 ? single && strcmp(single, "1") == 0 : !single); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/ts514_%d.db", tmp, mode); + cbm_pipeline_t *pipeline = + configured[mode] ? cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL) : NULL; + run_rc[mode] = pipeline ? cbm_pipeline_run(pipeline) : -1; + cbm_store_t *store = run_rc[mode] == 0 ? cbm_store_open_path(db_path) : NULL; + opened[mode] = store != NULL; + if (store) { + const char *project = cbm_pipeline_project_name(pipeline); + const char *file_values[] = {project}; + files[mode] = ts514_pipeline_count( + store, "SELECT count(*) FROM nodes WHERE project=?1 AND label='File'", file_values, + 1); + for (int i = 0; i < NCASES; i++) { + char *src = + cbm_pipeline_fqn_compute(project, paths[cases[i].file], cases[i].symbol); + char *tgt = cbm_pipeline_fqn_compute(project, paths[0], cases[i].target); + const char *src_values[] = {project, src, paths[cases[i].file]}; + const char *tgt_values[] = {project, tgt, paths[0]}; + source_nodes[mode][i] = ts514_pipeline_count(store, node_sql, src_values, 3); + target_nodes[mode][i] = ts514_pipeline_count(store, node_sql, tgt_values, 3); + const char *edge_values[] = { + project, src, paths[cases[i].file], + tgt, paths[0], cases[i].want ? "lsp_ts_method" : ""}; + edges[mode][i] = ts514_pipeline_count(store, edge_sql, edge_values, 6); + if (!cases[i].want) + unresolved_calls[mode][i - 5] = ts514_pipeline_count( + store, + "SELECT count(*) FROM edges e JOIN nodes s ON s.id=e.source_id " + "WHERE e.project=?1 AND e.type='CALLS' " + "AND s.qualified_name=?2 AND s.file_path=?3", + src_values, 3); + if (i == 1) { + edge_values[3] = src; + edge_values[4] = paths[cases[i].file]; + edge_values[5] = ""; + self_loop[mode] = ts514_pipeline_count(store, edge_sql, edge_values, 6); + } + if (i == 4) { + char *wrong = cbm_pipeline_fqn_compute(project, paths[0], "CatService.findAll"); + edge_values[3] = wrong; + edge_values[5] = ""; + annotation_wrong[mode] = ts514_pipeline_count(store, edge_sql, edge_values, 6); + free(wrong); + } + free(src); + free(tgt); + } + cbm_store_close(store); + } + cbm_pipeline_free(pipeline); + } + if (saved_workers) + cbm_setenv("CBM_WORKERS", saved_workers, 1); + else + cbm_unsetenv("CBM_WORKERS"); + if (saved_single) + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + else + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + const char *restored_workers = getenv("CBM_WORKERS"); + const char *restored_single = getenv("CBM_INDEX_SINGLE_THREAD"); + bool restored = + (saved_workers ? restored_workers && strcmp(saved_workers, restored_workers) == 0 + : !restored_workers) && + (saved_single ? restored_single && strcmp(saved_single, restored_single) == 0 + : !restored_single); + free(saved_workers); + free(saved_single); + th_rmtree(tmp); + + ASSERT_TRUE(extraction_ok); + ASSERT_TRUE(restored); + for (int i = 0; i < NCASES; i++) + ASSERT_EQ(occurrences[i], 1); + for (int mode = 0; mode < 2; mode++) { + ASSERT_TRUE(configured[mode]); + ASSERT_EQ(run_rc[mode], 0); + ASSERT_TRUE(opened[mode]); + ASSERT_GTE(files[mode], 55); + for (int i = 0; i < NCASES; i++) { + ASSERT_EQ(source_nodes[mode][i], 1); + ASSERT_EQ(target_nodes[mode][i], 1); + ASSERT_EQ(edges[mode][i], cases[i].want); + } + ASSERT_EQ(unresolved_calls[mode][0], 0); + ASSERT_EQ(unresolved_calls[mode][1], 0); + ASSERT_EQ(self_loop[mode], 0); + ASSERT_EQ(annotation_wrong[mode], 0); + } + PASS(); +} + TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_tsjs_recv_XXXXXX"); @@ -17230,6 +17478,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_axios_wrapper_baseurl_composes_http_calls_issue1916); RUN_TEST(pipeline_ts_crossfile_new_instance_method_call_issue1354); + RUN_TEST(pipeline_ts_param_property_injection_issue514); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_keeps_specific_unique_name_member_call); RUN_TEST(pipeline_html_embedded_member_call_stays_unbound); diff --git a/tests/test_ts_lsp.c b/tests/test_ts_lsp.c index c75397975..12b4ef2bd 100644 --- a/tests/test_ts_lsp.c +++ b/tests/test_ts_lsp.c @@ -1929,16 +1929,77 @@ TEST(tslsp_class_multi_inheritance_chain) { PASS(); } +/* #514 probes bind both positive and negative claims to one parser-backed + * occurrence. Same-named methods on other classes cannot satisfy them. */ +static const CBMCall *ts514_call_site(const CBMFileResult *r, const char *source, + const char *expression, const char *caller, + const char *callee) { + const char *site = strstr(source, expression); + if (!r || r->has_error || !site || strstr(site + 1, expression)) + return NULL; + uint32_t start = (uint32_t)(site - source); + uint32_t end = start + (uint32_t)strlen(expression); + const CBMCall *found = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *call = &r->calls.items[i]; + if (call->enclosing_func_qn && strcmp(call->enclosing_func_qn, caller) == 0 && + call->callee_name && strcmp(call->callee_name, callee) == 0 && + call->site_start_byte == start && call->site_end_byte == end && call->start_line > 0) { + if (found) + return NULL; + found = call; + } + } + return found; +} + +static int ts514_resolved_count(const CBMResolvedCallArray *resolved, const CBMCall *call, + const char *target, const char *strategy) { + if (!call) + return -1; + int count = 0; + for (int i = 0; i < resolved->count; i++) { + const CBMResolvedCall *rc = &resolved->items[i]; + if (rc->kind == CBM_RESOLVED_INVOCATION && rc->confidence > 0 && rc->caller_qn && + strcmp(rc->caller_qn, call->enclosing_func_qn) == 0 && rc->callee_qn && + (!target || strcmp(rc->callee_qn, target) == 0) && + rc->site_start_byte == call->site_start_byte && + rc->site_end_byte == call->site_end_byte && + (!strategy || (rc->strategy && strcmp(rc->strategy, strategy) == 0))) + count++; + } + return count; +} + +static int ts514_definition_count(const CBMFileResult *r, const char *qn) { + int count = 0; + for (int i = 0; r && i < r->defs.count; i++) { + if (r->defs.items[i].qualified_name && strcmp(r->defs.items[i].qualified_name, qn) == 0) + count++; + } + return count; +} + TEST(tslsp_class_constructor_param_property) { - /* TS shorthand: constructor params with access modifiers become fields. */ - CBMFileResult *r = extract_ts("class Tool { fire(): void {} }\n" - "class Box {\n" - " constructor(public tool: Tool) {}\n" - "}\n" - "function go(b: Box) { b.tool.fire(); }\n"); + const char *source = "class Tool { fire(): void {} }\n" + "class Box { constructor(public tool: Tool) {} }\n" + "function direct(directTool: Tool) { directTool.fire(); }\n" + "function throughBox(b: Box) { b.tool.fire(); }\n"; + CBMFileResult *r = extract_ts(source); ASSERT_NOT_NULL(r); - /* Constructor parameter property — accept smoke pass for v1 */ + const CBMCall *direct = + ts514_call_site(r, source, "directTool.fire()", "test.main.direct", "directTool.fire"); + const CBMCall *field = + ts514_call_site(r, source, "b.tool.fire()", "test.main.throughBox", "b.tool.fire"); + int target = ts514_definition_count(r, "test.main.Tool.fire"); + int positive = + ts514_resolved_count(&r->resolved_calls, direct, "test.main.Tool.fire", "lsp_ts_method"); + int property = + ts514_resolved_count(&r->resolved_calls, field, "test.main.Tool.fire", "lsp_ts_method"); cbm_free_result(r); + ASSERT_EQ(target, 1); + ASSERT_EQ(positive, 1); + ASSERT_EQ(property, 1); PASS(); } @@ -2246,6 +2307,253 @@ TEST(tslsp_crossfile_same_name_file_receiver_issue1354) { PASS(); } +/* #514: parameter properties participate in this-dispatch for each modifier + * form, while an unmarked constructor parameter remains a local parameter. */ +TEST(tslsp_param_property_this_dispatch_issue514) { + const char *source = + "class ServiceBase { findAll(): string[] { return []; } }\n" + "class CatService extends ServiceBase { findAll(): string[] { return []; } }\n" + "class Parent { inherited: ServiceBase; }\n" + "class CatController extends Parent {\n" + " constructor(private readonly catService: CatService, private a: CatService,\n" + " protected b: CatService, public c: CatService, readonly d: CatService,\n" + " public override inherited: CatService, plain: CatService,\n" + " private optional?: CatService) { super(); }\n" + " findAll() { return this.catService.findAll(); }\n" + " viaPrivate() { this.a.findAll(); }\n" + " viaProtected() { this.b.findAll(); }\n" + " viaPublic() { this.c.findAll(); }\n" + " viaReadonly() { this.d.findAll(); }\n" + " viaOverride() { this.inherited.findAll(); }\n" + " viaOptional() { this.optional.findAll(); }\n" + " viaPlain() { this.plain.findAll(); }\n" + "}\n" + "function direct(control: CatService) { control.findAll(); }\n"; + static const struct { + const char *expression; + const char *caller; + const char *callee; + } sites[] = { + {"control.findAll()", "test.main.direct", "control.findAll"}, + {"this.catService.findAll()", "test.main.CatController.findAll", "this.catService.findAll"}, + {"this.a.findAll()", "test.main.CatController.viaPrivate", "this.a.findAll"}, + {"this.b.findAll()", "test.main.CatController.viaProtected", "this.b.findAll"}, + {"this.c.findAll()", "test.main.CatController.viaPublic", "this.c.findAll"}, + {"this.d.findAll()", "test.main.CatController.viaReadonly", "this.d.findAll"}, + {"this.inherited.findAll()", "test.main.CatController.viaOverride", + "this.inherited.findAll"}, + {"this.optional.findAll()", "test.main.CatController.viaOptional", "this.optional.findAll"}, + {"this.plain.findAll()", "test.main.CatController.viaPlain", "this.plain.findAll"}, + }; + CBMFileResult *r = extract_ts(source); + ASSERT_NOT_NULL(r); + int target = ts514_definition_count(r, "test.main.CatService.findAll"); + int controller = ts514_definition_count(r, "test.main.CatController.findAll"); + int base = ts514_definition_count(r, "test.main.ServiceBase.findAll"); + int resolved[9], self_loop = -1, wrong_override = -1; + bool extracted[9]; + for (size_t i = 0; i < 9; i++) { + const CBMCall *call = + ts514_call_site(r, source, sites[i].expression, sites[i].caller, sites[i].callee); + extracted[i] = call != NULL; + resolved[i] = ts514_resolved_count(&r->resolved_calls, call, + i == 8 ? NULL : "test.main.CatService.findAll", + i == 8 ? NULL : "lsp_ts_method"); + if (i == 6) + wrong_override = ts514_resolved_count(&r->resolved_calls, call, + "test.main.ServiceBase.findAll", NULL); + if (i == 1) + self_loop = ts514_resolved_count(&r->resolved_calls, call, + "test.main.CatController.findAll", NULL); + } + cbm_free_result(r); + ASSERT_EQ(target, 1); + ASSERT_EQ(controller, 1); + ASSERT_EQ(base, 1); + for (size_t i = 0; i < 9; i++) + ASSERT_TRUE(extracted[i]); + for (size_t i = 0; i < 8; i++) + ASSERT_EQ(resolved[i], 1); + ASSERT_EQ(resolved[8], 0); + ASSERT_EQ(self_loop, 0); + ASSERT_EQ(wrong_override, 0); + PASS(); +} + +/* Only direct new-expression initializers infer a field type. An annotation + * wins even when unknown, and a factory call is outside this inference. */ +TEST(tslsp_field_new_initializer_issue514) { + const char *source = "class Tool { fire(): void {} }\n" + "class Base { run(): void {} }\n" + "class Derived extends Base { run(): void {} }\n" + "function makeTool(): Tool { return new Tool(); }\n" + "class Holder {\n" + " private fresh = new Tool();\n" + " private annotated: Base = new Derived();\n" + " private opaque: unknown = new Tool();\n" + " private factory = makeTool();\n" + " viaNew() { this.fresh.fire(); }\n" + " viaAnnotated() { this.annotated.run(); }\n" + " viaOpaque() { this.opaque.fire(); }\n" + " viaFactory() { this.factory.fire(); }\n" + "}\n" + "function direct(control: Tool) { control.fire(); }\n"; + CBMFileResult *r = extract_ts(source); + ASSERT_NOT_NULL(r); + const CBMCall *direct = + ts514_call_site(r, source, "control.fire()", "test.main.direct", "control.fire"); + const CBMCall *fresh = ts514_call_site(r, source, "this.fresh.fire()", + "test.main.Holder.viaNew", "this.fresh.fire"); + const CBMCall *annotated = ts514_call_site( + r, source, "this.annotated.run()", "test.main.Holder.viaAnnotated", "this.annotated.run"); + const CBMCall *opaque = ts514_call_site(r, source, "this.opaque.fire()", + "test.main.Holder.viaOpaque", "this.opaque.fire"); + const CBMCall *factory = ts514_call_site(r, source, "this.factory.fire()", + "test.main.Holder.viaFactory", "this.factory.fire"); + bool extracted = direct && fresh && annotated && opaque && factory; + int tool = ts514_definition_count(r, "test.main.Tool.fire"); + int base = ts514_definition_count(r, "test.main.Base.run"); + int derived = ts514_definition_count(r, "test.main.Derived.run"); + int control = + ts514_resolved_count(&r->resolved_calls, direct, "test.main.Tool.fire", "lsp_ts_method"); + int inferred = + ts514_resolved_count(&r->resolved_calls, fresh, "test.main.Tool.fire", "lsp_ts_method"); + int declared = + ts514_resolved_count(&r->resolved_calls, annotated, "test.main.Base.run", "lsp_ts_method"); + int wrong = ts514_resolved_count(&r->resolved_calls, annotated, "test.main.Derived.run", NULL); + int unknown = ts514_resolved_count(&r->resolved_calls, opaque, NULL, NULL); + int arbitrary = ts514_resolved_count(&r->resolved_calls, factory, NULL, NULL); + cbm_free_result(r); + ASSERT_TRUE(extracted); + ASSERT_EQ(tool, 1); + ASSERT_EQ(base, 1); + ASSERT_EQ(derived, 1); + ASSERT_EQ(control, 1); + ASSERT_EQ(inferred, 1); + ASSERT_EQ(declared, 1); + ASSERT_EQ(wrong, 0); + ASSERT_EQ(unknown, 0); + ASSERT_EQ(arbitrary, 0); + PASS(); +} + +/* Both cross-file entry points refine only their owned registry. The shared + * sealed registry must not acquire field arrays owned by a per-file arena. */ +TEST(tslsp_crossfile_param_property_issue514) { + CBMLSPDef defs[] = { + {.qualified_name = "test.cat_service.CatService", + .short_name = "CatService", + .label = "Class", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.cat_service", + .method_names_str = "findAll"}, + {.qualified_name = "test.cat_service.CatService.findAll", + .short_name = "findAll", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.cat_service", + .receiver_type = "test.cat_service.CatService"}, + {.qualified_name = "test.cat_controller.CatController", + .short_name = "CatController", + .label = "Class", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.cat_controller", + .method_names_str = "findAll|viaNew|direct"}, + {.qualified_name = "test.cat_controller.CatController.findAll", + .short_name = "findAll", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.cat_controller", + .receiver_type = "test.cat_controller.CatController"}, + {.qualified_name = "test.cat_controller.CatController.viaNew", + .short_name = "viaNew", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.cat_controller", + .receiver_type = "test.cat_controller.CatController"}, + {.qualified_name = "test.cat_controller.CatController.direct", + .short_name = "direct", + .label = "Method", + .lang = CBM_LANG_TYPESCRIPT, + .def_module_qn = "test.cat_controller", + .receiver_type = "test.cat_controller.CatController"}, + }; + enum { NDEFS = (int)(sizeof(defs) / sizeof(defs[0])) }; + const char *imp_names[] = {"CatService"}; + const char *imp_qns[] = {"test.cat_service"}; + const char *source = "import { CatService } from './cat.service';\n" + "export class CatController {\n" + " private fresh = new CatService();\n" + " constructor(private readonly catService: CatService) {}\n" + " findAll() { return this.catService.findAll(); }\n" + " viaNew() { return this.fresh.findAll(); }\n" + " direct(control: CatService) { return control.findAll(); }\n" + "}\n"; + CBMFileResult *r = extract_with(source, CBM_LANG_TYPESCRIPT, "cat_controller.ts"); + ASSERT_NOT_NULL(r); + const CBMCall *injected = + ts514_call_site(r, source, "this.catService.findAll()", + "test.cat_controller.CatController.findAll", "this.catService.findAll"); + const CBMCall *fresh = + ts514_call_site(r, source, "this.fresh.findAll()", + "test.cat_controller.CatController.viaNew", "this.fresh.findAll"); + const CBMCall *direct = + ts514_call_site(r, source, "control.findAll()", "test.cat_controller.CatController.direct", + "control.findAll"); + bool extracted = injected && fresh && direct; + CBMArena shared_arena; + cbm_arena_init(&shared_arena); + CBMTypeRegistry *reg = cbm_ts_build_cross_registry(&shared_arena, defs, NDEFS); + int counts[2][4] = {{-1, -1, -1, -1}, {-1, -1, -1, -1}}; + bool untouched = false; + if (reg) { + const CBMRegisteredType *before = + cbm_registry_lookup_type(reg, "test.cat_controller.CatController"); + const char **fields = before ? before->field_names : NULL; + const CBMType **types = before ? before->field_types : NULL; + int type_count = reg->type_count; + untouched = before && reg->read_only; + for (int mode = 0; mode < 2; mode++) { + CBMArena file_arena; + cbm_arena_init(&file_arena); + CBMResolvedCallArray out = {0}; + if (mode == 0) + cbm_run_ts_lsp_cross_with_registry(&file_arena, source, (int)strlen(source), + "test.cat_controller", false, false, false, reg, + defs, NDEFS, imp_names, imp_qns, 1, NULL, &out); + else + cbm_run_ts_lsp_cross(&file_arena, source, (int)strlen(source), + "test.cat_controller", false, false, false, defs, NDEFS, + imp_names, imp_qns, 1, NULL, &out); + counts[mode][0] = ts514_resolved_count( + &out, direct, "test.cat_service.CatService.findAll", "lsp_ts_method"); + counts[mode][1] = ts514_resolved_count( + &out, injected, "test.cat_service.CatService.findAll", "lsp_ts_method"); + counts[mode][2] = ts514_resolved_count( + &out, fresh, "test.cat_service.CatService.findAll", "lsp_ts_method"); + counts[mode][3] = ts514_resolved_count( + &out, injected, "test.cat_controller.CatController.findAll", NULL); + cbm_arena_destroy(&file_arena); + const CBMRegisteredType *after = + cbm_registry_lookup_type(reg, "test.cat_controller.CatController"); + untouched = untouched && after == before && after->field_names == fields && + after->field_types == types && reg->type_count == type_count && + reg->read_only; + } + } + cbm_arena_destroy(&shared_arena); + cbm_free_result(r); + ASSERT_TRUE(extracted); + ASSERT_TRUE(untouched); + for (int mode = 0; mode < 2; mode++) { + ASSERT_EQ(counts[mode][0], 1); + ASSERT_EQ(counts[mode][1], 1); + ASSERT_EQ(counts[mode][2], 1); + ASSERT_EQ(counts[mode][3], 0); + } + PASS(); +} + /* Issue #344 / #340: SIGSEGV in the TS cross-file LSP pass when the collected * def array exceeded ~1189 entries — scale-dependent, not file-specific. Drive * the resolver with a def array far past that threshold; the v0.7.0 rewrite @@ -4679,6 +4987,9 @@ SUITE(ts_lsp) { RUN_TEST(tslsp_crossfile_method_dispatch); RUN_TEST(tslsp_crossfile_new_expression_receiver_issue1354); RUN_TEST(tslsp_crossfile_same_name_file_receiver_issue1354); + RUN_TEST(tslsp_param_property_this_dispatch_issue514); + RUN_TEST(tslsp_field_new_initializer_issue514); + RUN_TEST(tslsp_crossfile_param_property_issue514); RUN_TEST(tslsp_scale_many_defs_no_crash_issue344); RUN_TEST(tslsp_crossfile_function_call); RUN_TEST(tslsp_crossfile_chain_through_return);