From 14d4388315911e3b23bea8fd35a68126d5dee9df Mon Sep 17 00:00:00 2001 From: Hubert Tarnacki Date: Wed, 9 Sep 2026 22:43:39 +0200 Subject: [PATCH] feat(scala): parse import selectors and resolve package-aware imports Scala imports were parsed with the generic import fallback, which ignored selector groups, aliases (`{A => B}`) and wildcards, and the declared `package` clause was not recorded. IMPORTS edges then had to be guessed from the physical directory layout, which does not have to mirror the package in Scala projects. - parse Scala `import` statements into one CBMImport per selector, keeping the alias as the local name and the full path as the target - record the `package` clause as the file namespace; consecutive braceless clauses (`package a` / `package b`) are joined into `a.b` - thread the file language through import resolution so Scala imports are resolved against declared packages; the resolver fails closed at every step instead of guessing - build a (package, top-level name) -> node index once per pass, next to the namespace map, so package membership is an O(1) lookup instead of a scan of the package's file list per same-named hit; every file declaring a namespace contributes, so a Scala import of a Java class in a mixed package resolves. Only the Scala resolver reads it, so it is built only when a Scala file is among the pass's files - "top level" is decided against the file's language-aware module QN (stem vs directory), so a Java nested type (`Request.Builder`, one segment below the directory module) is not mistaken for a top-level `Builder` of the package - `import pkg.Name` binds only a top-level `Name` of a file declaring `pkg`: nested classes and methods neither match nor make the real top-level symbol look ambiguous; two top-level declarations of the same name in a split package yield no edge - `import pkg.Owner.member` binds the member under the owner's QN and otherwise yields no edge, so a missing member never turns `member()` into a CALLS edge to the owner class via resolve_import_map - `import pkg._` binds the lexicographically smallest declaring file so the target is identical on every platform; the index records that file (and the runner-up, for self-import) per package, so the pick is O(1) instead of a scan of the package's file list - incremental parity: both incremental routes hand the passes only the changed files, so the namespace map and the Scala index used to know only the changed files' packages, and an import into an unchanged package resolved through the symbol fallback (or, fail-closed, not at all) until the next full index. The passes now persist each file's package clause on its File node, and the incremental routes read the clauses of the files that stay back into the pass context so the maps cover the whole project exactly as on a full build - Scala package clauses feed only the Scala resolver: the generic first-declaring-file walk skips Scala files, so Java/Kotlin imports of a shared package prefix keep resolving exactly as before - import-map lookups therefore bind an aliased local name to the exact imported object, so `Alias.method()` resolves with the `import_map` strategy Tests: extraction of selectors/aliases, the package namespace and chained package clauses; edge_imports guards that packages may ignore the physical layout, that a Java import never binds to a Scala prefix declarer, that a Scala import binds a Java class of a mixed package, that a Java import of a package declared by Java and Scala files still binds the Java file, that two same-named top-level symbols in a split package give no edge, that a nested same-named symbol is ignored, that an unresolvable `Owner.member` gives neither an IMPORTS nor a CALLS edge to the owner, that chained package clauses resolve, that a Java nested type of the same name neither binds nor makes the top-level Scala symbol ambiguous, and that an incremental reindex (closure-repair and legacy-partial route, each proven by the route probe) keeps the IMPORTS and CALLS into an unchanged package; pipeline tests for alias resolution (sequential + parallel), for member imports in a split package, and that the Scala index is built only when a Scala file is among the importers. Closes #2153 Signed-off-by: Hubert Tarnacki --- internal/cbm/extract_imports.c | 180 ++++++++++- scripts/memory-core-baseline.txt | 8 +- src/pipeline/pass_calls.c | 9 +- src/pipeline/pass_definitions.c | 34 +- src/pipeline/pass_parallel.c | 42 ++- src/pipeline/pass_pkgmap.c | 480 +++++++++++++++++++++++++--- src/pipeline/pipeline_incremental.c | 141 +++++++- src/pipeline/pipeline_internal.h | 75 +++-- tests/test_edge_imports.c | 462 ++++++++++++++++++++++++++ tests/test_extraction.c | 82 ++++- tests/test_pipeline.c | 219 ++++++++++++- 11 files changed, 1609 insertions(+), 123 deletions(-) diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c index 4aa0baa759..a9432b1180 100644 --- a/internal/cbm/extract_imports.c +++ b/internal/cbm/extract_imports.c @@ -24,6 +24,7 @@ static void parse_go_imports(CBMExtractCtx *ctx); static void parse_python_imports(CBMExtractCtx *ctx); static void parse_es_imports(CBMExtractCtx *ctx); static void parse_java_imports(CBMExtractCtx *ctx); +static void parse_scala_imports(CBMExtractCtx *ctx); static void parse_rust_imports(CBMExtractCtx *ctx); static void parse_c_imports(CBMExtractCtx *ctx); static void parse_ruby_imports(CBMExtractCtx *ctx); @@ -984,6 +985,146 @@ static void parse_generic_imports(CBMExtractCtx *ctx, const char *node_type) { ts_tree_cursor_delete(&cursor); } +// --- Scala imports --- + +static char *scala_trim_span(CBMArena *a, const char *start, const char *end) { + while (start < end && isspace((unsigned char)*start)) { + start++; + } + while (end > start && isspace((unsigned char)end[-1])) { + end--; + } + return cbm_arena_strndup(a, start, (size_t)(end - start)); +} + +static void scala_emit_import(CBMExtractCtx *ctx, const char *path, const char *local) { + if (!path || !path[0] || !local || !local[0]) { + return; + } + CBMImport imp = {.local_name = cbm_arena_strdup(ctx->arena, local), + .module_path = cbm_arena_strdup(ctx->arena, path)}; + cbm_imports_push(&ctx->result->imports, ctx->arena, imp); +} + +static void scala_parse_selector(CBMExtractCtx *ctx, const char *prefix, const char *start, + const char *end) { + char *selector = scala_trim_span(ctx->arena, start, end); + if (!selector || !selector[0] || + (strncmp(selector, "given", 5) == 0 && + (selector[5] == '\0' || isspace((unsigned char)selector[5])))) { + return; + } + if (strcmp(selector, "*") == 0 || strcmp(selector, "_") == 0) { + scala_emit_import(ctx, prefix, "*"); + return; + } + + char *rename = strstr(selector, "=>"); + size_t op_len = 2; + if (!rename) { + rename = strstr(selector, " as "); + op_len = 4; + } + char *name = selector; + char *local = selector; + if (rename) { + local = scala_trim_span(ctx->arena, rename + op_len, selector + strlen(selector)); + name = scala_trim_span(ctx->arena, selector, rename); + if (strcmp(local, "_") == 0 || strcmp(local, "*") == 0) { + return; // Scala 2 hidden selector: `Name => _` + } + } + if (!name[0] || !local[0]) { + return; + } + const char *path = + prefix && prefix[0] ? cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, name) : name; + scala_emit_import(ctx, path, local); +} + +static void scala_parse_import_expr(CBMExtractCtx *ctx, const char *start, const char *end) { + char *expr = scala_trim_span(ctx->arena, start, end); + if (!expr || !expr[0]) { + return; + } + char *open = strchr(expr, '{'); + char *close = strrchr(expr, '}'); + if (open && close && close > open) { + char *prefix = scala_trim_span(ctx->arena, expr, open); + size_t plen = strlen(prefix); + while (plen > 0 && (prefix[plen - 1] == '.' || isspace((unsigned char)prefix[plen - 1]))) { + prefix[--plen] = '\0'; + } + const char *part = open + 1; + for (const char *p = part;; p++) { + if (p == close || *p == ',') { + scala_parse_selector(ctx, prefix, part, p); + if (p == close) { + break; + } + part = p + 1; + } + } + return; + } + + char *rename = strstr(expr, " as "); + if (rename) { + char *path = scala_trim_span(ctx->arena, expr, rename); + char *local = scala_trim_span(ctx->arena, rename + 4, expr + strlen(expr)); + scala_emit_import(ctx, path, local); + return; + } + size_t len = strlen(expr); + if ((len > 2 && strcmp(expr + len - 2, ".*") == 0) || + (len > 2 && strcmp(expr + len - 2, "._") == 0)) { + expr[len - 2] = '\0'; + scala_emit_import(ctx, expr, "*"); + return; + } + if (len > 6 && strcmp(expr + len - 6, ".given") == 0) { + expr[len - 6] = '\0'; + scala_emit_import(ctx, expr, "*"); + return; + } + scala_emit_import(ctx, expr, path_last(ctx->arena, expr)); +} + +static void parse_scala_imports(CBMExtractCtx *ctx) { + TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); + if (!ts_tree_cursor_goto_first_child(&cursor)) { + ts_tree_cursor_delete(&cursor); + return; + } + do { + TSNode node = ts_tree_cursor_current_node(&cursor); + if (strcmp(ts_node_type(node), "import_declaration") != 0) { + continue; + } + char *text = cbm_node_text(ctx->arena, node, ctx->source); + if (!text || strncmp(text, "import", 6) != 0) { + continue; + } + const char *part = text + 6; + int brace_depth = 0; + for (const char *p = part;; p++) { + if (*p == '{') { + brace_depth++; + } else if (*p == '}') { + brace_depth--; + } + if (*p == '\0' || (*p == ',' && brace_depth == 0)) { + scala_parse_import_expr(ctx, part, p); + if (*p == '\0') { + break; + } + part = p + 1; + } + } + } while (ts_tree_cursor_goto_next_sibling(&cursor)); + ts_tree_cursor_delete(&cursor); +} + // --- Kotlin imports --- // tree-sitter-kotlin nests imports: source_file -> import_list -> import_header*. // parse_generic_imports only scans the DIRECT children of root, and "import" is @@ -1659,6 +1800,37 @@ static void parse_embedded_imports(CBMExtractCtx *ctx) { } } +// Scala chains braceless clauses: `package a` followed by `package b` puts the +// file's symbols in a.b. Append every directly following braceless +// package_clause to the namespace already recorded from the first one. A +// braced clause (`package b { ... }`) scopes only its body and is left alone. +static void join_chained_scala_packages(CBMExtractCtx *ctx, TSTreeCursor *cursor) { + while (ts_tree_cursor_goto_next_sibling(cursor)) { + TSNode next = ts_tree_cursor_current_node(cursor); + const char *next_kind = ts_node_type(next); + if (strcmp(next_kind, "comment") == 0 || strcmp(next_kind, "block_comment") == 0) { + continue; + } + if (strcmp(next_kind, "package_clause") != 0) { + return; + } + TSNode body = next; + if (find_first_descendant_of(next, "template_body", &body)) { + return; + } + TSNode name_node = next; + if (!find_first_descendant_of(next, "package_identifier", &name_node)) { + return; + } + char *segment = cbm_node_text(ctx->arena, name_node, ctx->source); + if (!segment || !segment[0]) { + return; + } + ctx->result->namespace_name = + cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->result->namespace_name, segment); + } +} + // --- Namespace / package declaration capture --- // Java/Kotlin/C#/PHP put the file's symbols inside a namespace/package whose // name is NOT reflected in the path-based QN scheme. Capturing it lets the @@ -1670,6 +1842,7 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { "file_scoped_namespace_declaration", // C# 10 "package_declaration", // Java / Kotlin "package_header", // Kotlin + "package_clause", // Scala "namespace_definition", // PHP NULL}; TSTreeCursor cursor = ts_tree_cursor_new(ctx->root); @@ -1686,6 +1859,7 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { // The namespace name is the first qualified_name / scoped_identifier / // namespace_name / identifier descendant. static const char *name_kinds[] = {"qualified_name", + "package_identifier", "scoped_identifier", "namespace_name", "identifier", @@ -1703,6 +1877,9 @@ static void capture_namespace_decl(CBMExtractCtx *ctx) { } } if (ctx->result->namespace_name) { + if (strcmp(kind, "package_clause") == 0) { + join_chained_scala_packages(ctx, &cursor); + } break; } } while (ts_tree_cursor_goto_next_sibling(&cursor)); @@ -2969,6 +3146,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { switch (ctx->language) { case CBM_LANG_JAVA: case CBM_LANG_KOTLIN: + case CBM_LANG_SCALA: case CBM_LANG_CSHARP: case CBM_LANG_PHP: capture_namespace_decl(ctx); @@ -2996,7 +3174,7 @@ void cbm_extract_imports(CBMExtractCtx *ctx) { parse_kotlin_imports(ctx); break; case CBM_LANG_SCALA: - parse_generic_imports(ctx, "import_declaration"); + parse_scala_imports(ctx); break; case CBM_LANG_CSHARP: parse_csharp_imports(ctx); diff --git a/scripts/memory-core-baseline.txt b/scripts/memory-core-baseline.txt index 1453388034..a213ebc5c4 100644 --- a/scripts/memory-core-baseline.txt +++ b/scripts/memory-core-baseline.txt @@ -62,7 +62,7 @@ src/pipeline/pass_compile_commands.c 17 src/pipeline/pass_complexity.c 15 src/pipeline/pass_configlink.c 3 src/pipeline/pass_cross_repo.c 8 -src/pipeline/pass_definitions.c 14 +src/pipeline/pass_definitions.c 12 src/pipeline/pass_enrichment.c 15 src/pipeline/pass_ensemble_routing.c 10 src/pipeline/pass_envscan.c 2 @@ -71,15 +71,15 @@ src/pipeline/pass_importance.c 4 src/pipeline/pass_infrascan.c 2 src/pipeline/pass_k8s.c 6 src/pipeline/pass_lsp_cross.c 50 -src/pipeline/pass_parallel.c 31 -src/pipeline/pass_pkgmap.c 82 +src/pipeline/pass_parallel.c 29 +src/pipeline/pass_pkgmap.c 80 src/pipeline/pass_semantic.c 3 src/pipeline/pass_tests.c 3 src/pipeline/pass_usages.c 4 src/pipeline/path_alias.c 42 src/pipeline/pipeline.c 134 src/pipeline/pipeline_delta.c 19 -src/pipeline/pipeline_incremental.c 144 +src/pipeline/pipeline_incremental.c 142 src/pipeline/registry.c 17 src/pipeline/worker_pool.c 2 src/store/store.c 358 diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 6689196820..827fb447aa 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -118,7 +118,7 @@ static const cbm_gbuf_node_t *pc_find_by_computed_qn(const cbm_gbuf_t *gbuf, con return n; } -static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, +static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, CBMLanguage language, const CBMFileResult *result, const char ***out_keys, const char ***out_vals, int *out_count) { *out_keys = NULL; @@ -141,7 +141,7 @@ static int build_import_map(cbm_pipeline_ctx_t *ctx, const char *rel_path, continue; } const cbm_gbuf_node_t *target = - cbm_pipeline_resolve_import_node(ctx, rel_path, file_qn, imp, NULL); + cbm_pipeline_resolve_import_node(ctx, rel_path, file_qn, language, imp, NULL, NULL); if (!target) { continue; } @@ -1053,7 +1053,7 @@ int cbm_pipeline_pass_calls(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *file const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - build_import_map(ctx, rel, result, &imp_keys, &imp_vals, &imp_count); + build_import_map(ctx, rel, files[i].language, result, &imp_keys, &imp_vals, &imp_count); /* Compute module QN for same-module resolution (directory-based for * Java/Go so it matches their def-node QNs in the registry). */ @@ -1216,7 +1216,8 @@ void cbm_pipeline_pass_fastapi_depends(cbm_pipeline_ctx_t *ctx, const cbm_file_i const char **imp_keys = NULL; const char **imp_vals = NULL; int imp_count = 0; - build_import_map(ctx, files[i].rel_path, result, &imp_keys, &imp_vals, &imp_count); + build_import_map(ctx, files[i].rel_path, files[i].language, result, &imp_keys, &imp_vals, + &imp_count); for (int d = 0; d < result->defs.count; d++) { CBMDefinition *def = &result->defs.items[d]; diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index f3a2cd948c..f7c2f4002f 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -512,7 +512,9 @@ int cbm_pipeline_create_env_configures_for_file(cbm_pipeline_ctx_t *ctx, /* Create IMPORTS edges for one file's imports. Mirrors the resolution * logic in pass_parallel.c register_and_link_def — keep the two in sync. */ static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, - const char *rel, CBMHashTable *namespace_map) { + const char *rel, CBMLanguage language, + CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { int count = 0; char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *source_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -525,8 +527,8 @@ static int create_import_edges_for_file(cbm_pipeline_ctx_t *ctx, const CBMFileRe if (!imp->module_path) { continue; } - const cbm_gbuf_node_t *target = - cbm_pipeline_resolve_import_node(ctx, rel, file_qn, imp, namespace_map); + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + ctx, rel, file_qn, language, imp, namespace_map, scala_index); if (target && target->id != source_node->id) { char esc_ln[CBM_SZ_128]; cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : ""); @@ -938,7 +940,8 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * resolve to defs already in the graph, but the file's * own defs are now persisted before the lookup. No namespace * map is available without the cache (single-file scope). */ - total_imports += create_import_edges_for_file(ctx, result, rel, NULL); + total_imports += + create_import_edges_for_file(ctx, result, rel, files[i].language, NULL, NULL); create_channel_edges_for_file(ctx, result, rel); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); cbm_free_result(result); @@ -952,16 +955,19 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t if (local_cache) { /* Build a namespace/package → File-QN map so that namespace imports * (C# `using`, Java/Kotlin `import`, PHP `use`) resolve to the file - * that declares the namespace. */ - const char **rels = (const char **)calloc((size_t)file_count, sizeof(char *)); - if (rels) { + * that declares the namespace, and the Scala top-level index. */ + CBMHashTable *namespace_map = NULL; + cbm_scala_index_t *scala_index = NULL; + const char **namespaces = + cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(char *)); + if (namespaces) { for (int i = 0; i < file_count; i++) { - rels[i] = files[i].rel_path; + namespaces[i] = local_cache[i] ? local_cache[i]->namespace_name : NULL; } + cbm_pipeline_import_maps_build(ctx, files, file_count, namespaces, &namespace_map, + &scala_index); + cbm_free(CBM_MEM_CLASS_OTHER, namespaces); } - CBMHashTable *namespace_map = - cbm_pipeline_namespace_map_build(ctx->project_name, local_cache, rels, file_count); - free(rels); for (int i = 0; i < file_count; i++) { if (cbm_pipeline_check_cancel(ctx)) { break; @@ -970,12 +976,12 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t if (!result) { continue; } - total_imports += - create_import_edges_for_file(ctx, result, files[i].rel_path, namespace_map); + total_imports += create_import_edges_for_file( + ctx, result, files[i].rel_path, files[i].language, namespace_map, scala_index); create_channel_edges_for_file(ctx, result, files[i].rel_path); cbm_pipeline_create_env_configures_for_file(ctx, result, files[i].rel_path); } - cbm_pipeline_namespace_map_free(namespace_map); + cbm_pipeline_import_maps_free(namespace_map, scala_index); if (owns_local_cache) { for (int i = 0; i < file_count; i++) { if (local_cache[i]) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a8a9f36933..eab579f7d4 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -1750,7 +1750,8 @@ static void pp_add_file_doc(const cbm_gbuf_node_t *file_node, const char *doc) { /* Create IMPORTS edges for one file's imports (parallel path). */ static int create_imports_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, - const char *rel, CBMHashTable *namespace_map) { + const char *rel, CBMLanguage language, CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { int count = 0; char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__"); const cbm_gbuf_node_t *source_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); @@ -1763,8 +1764,8 @@ static int create_imports_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *re if (!imp->module_path) { continue; } - const cbm_gbuf_node_t *target = - cbm_pipeline_resolve_import_node(ctx, rel, file_qn, imp, namespace_map); + const cbm_gbuf_node_t *target = cbm_pipeline_resolve_import_node( + ctx, rel, file_qn, language, imp, namespace_map, scala_index); if (target && target->id != source_node->id) { char esc_ln[CBM_SZ_128]; cbm_json_escape(esc_ln, sizeof(esc_ln), imp->local_name ? imp->local_name : ""); @@ -1831,36 +1832,30 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t int imports_edges = 0; /* Namespace/package → File-QN map for namespace imports (C# `using`, - * Java/Kotlin `import`, PHP `use`). Built from the full result cache so - * every declaring file is visible regardless of loop order. */ - const char **rels = (const char **)calloc((size_t)file_count, sizeof(char *)); - if (rels) { - for (int i = 0; i < file_count; i++) { - rels[i] = files[i].rel_path; - } - } - /* Built from every file, including the ones already parked on disk: their - * results are NULL in result_cache, and a file absent from this map does - * not fail to resolve, it resolves through the looser fallback. Spilling - * therefore used to CHANGE the graph rather than merely delay it -- php - * measured 57,182 edges in memory against 59,379 while spilling, the same - * binary and corpus (2026-09-18), differing in both directions. */ + * Java/Kotlin `import`, PHP `use`) and the Scala top-level index. Built + * from the full result cache so every declaring file is visible + * regardless of loop order, including the files already parked on disk: + * their results are NULL in result_cache, and a file absent from this map + * does not fail to resolve, it resolves through the looser fallback. + * Spilling therefore used to CHANGE the graph rather than merely delay it + * -- php measured 57,182 edges in memory against 59,379 while spilling, + * the same binary and corpus (2026-09-18), differing in both directions. */ const char **namespaces = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)file_count * sizeof(char *)); CBMHashTable *namespace_map = NULL; + cbm_scala_index_t *scala_index = NULL; if (namespaces) { for (int i = 0; i < file_count; i++) { namespaces[i] = result_cache[i] ? result_cache[i]->namespace_name : cbm_result_spill_namespace(ctx->spill, i); } - namespace_map = - cbm_pipeline_namespace_map_build_names(ctx->project_name, namespaces, rels, file_count); + cbm_pipeline_import_maps_build(ctx, files, file_count, namespaces, &namespace_map, + &scala_index); cbm_free(CBM_MEM_CLASS_OTHER, namespaces); } - free(rels); for (int i = 0; i < file_count; i++) { if (cbm_pipeline_check_cancel(ctx)) { - cbm_pipeline_namespace_map_free(namespace_map); + cbm_pipeline_import_maps_free(namespace_map, scala_index); return CBM_NOT_FOUND; } @@ -1889,7 +1884,8 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t } } - imports_edges += create_imports_edges(ctx, result, rel, namespace_map); + imports_edges += + create_imports_edges(ctx, result, rel, files[i].language, namespace_map, scala_index); create_channel_edges(ctx, result, rel); cbm_pipeline_create_env_configures_for_file(ctx, result, rel); if (loaded) { @@ -1897,7 +1893,7 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t } } - cbm_pipeline_namespace_map_free(namespace_map); + cbm_pipeline_import_maps_free(namespace_map, scala_index); cbm_log_info("parallel.registry.done", "entries", itoa_log(reg_entries), "defines", itoa_log(defines_edges), "imports", itoa_log(imports_edges)); diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index 9c4ace14b0..e8523b89a2 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -2134,11 +2134,158 @@ static psr4_outcome_t resolve_php_psr4_class(const cbm_pipeline_ctx_t *ctx, return covered ? PSR4_UNRESOLVED : PSR4_NOT_APPLICABLE; } +/* ── Scala top-level index ───────────────────────────────────────── */ + +/* The two File nodes with the lexicographically smallest QNs declaring a + * package, borrowed from the graph buffer. The smallest is the `import pkg._` + * target on every platform regardless of directory-walk order; the runner-up + * serves when the smallest is the importing file itself. */ +typedef struct { + const cbm_gbuf_node_t *first; + const cbm_gbuf_node_t *second; /* NULL for a single-file package */ +} scala_package_files_t; + +struct cbm_scala_index { + CBMHashTable *symbols; /* "pkg.Name" → node, or &scala_toplevel_ambiguous */ + CBMHashTable *packages; /* "pkg" → scala_package_files_t */ +}; + +/* Marker value in the Scala top-level index: two files of a split package + * declare different top-level symbols under the same name, so an import of + * that name is ambiguous and must not bind. */ +static const cbm_gbuf_node_t scala_toplevel_ambiguous; + +static const cbm_gbuf_node_t *scala_toplevel_lookup(const cbm_scala_index_t *scala_index, + const char *package, const char *name) { + if (!scala_index || !scala_index->symbols || !package || !name || !name[0]) { + return NULL; + } + char key[PKGMAP_PATH_BUF]; + if (snprintf(key, sizeof(key), "%s.%s", package, name) >= (int)sizeof(key)) { + return NULL; + } + const cbm_gbuf_node_t *node = (const cbm_gbuf_node_t *)cbm_ht_get(scala_index->symbols, key); + return node == &scala_toplevel_ambiguous ? NULL : node; +} + +/* `import pkg._` binds the package itself: its smallest declaring file, or + * the runner-up when that is the importer. */ +static const cbm_gbuf_node_t *scala_package_file(const cbm_scala_index_t *scala_index, + const char *package, const char *source_file_qn) { + if (!scala_index || !scala_index->packages) { + return NULL; + } + const scala_package_files_t *pf = cbm_ht_get(scala_index->packages, package); + if (!pf) { + return NULL; + } + const cbm_gbuf_node_t *pick = pf->first; + if (source_file_qn && pick && strcmp(pick->qualified_name, source_file_qn) == 0) { + pick = pf->second; + } + return pick; +} + +/* Scala source packages are independent of the repository layout. Resolve the + * longest declared package prefix first, then the imported top-level symbol + * through the (package, name) index, then an optional member under that + * owner's QN. Every step fails closed: generic suffix fallback is unsafe for + * JVM FQNs because common names such as api/get/app occur throughout a + * monorepo, and a member import that binds the owner instead would turn + * `member()` into a CALLS edge to the owner class via resolve_import_map. */ +static const cbm_gbuf_node_t *resolve_scala_namespace_import(const cbm_pipeline_ctx_t *ctx, + const char *source_file_qn, + const CBMImport *imp, + const CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { + if (!namespace_map || !imp || !imp->module_path || !imp->module_path[0]) { + return NULL; + } + char full[PKGMAP_PATH_BUF]; + snprintf(full, sizeof(full), "%s", imp->module_path); + for (char *p = full; *p; p++) { + if (*p == '\\' || *p == ':' || *p == '/') { + *p = '.'; + } + } + size_t full_len = strlen(full); + while (full_len > 0 && (full[full_len - 1] == '*' || full[full_len - 1] == '.')) { + full[--full_len] = '\0'; + } + + bool wildcard = imp->local_name && strcmp(imp->local_name, "*") == 0; + if (wildcard && cbm_ht_has(namespace_map, full)) { + return scala_package_file(scala_index, full, source_file_qn); + } + + char package[PKGMAP_PATH_BUF]; + snprintf(package, sizeof(package), "%s", full); + for (;;) { + char *dot = strrchr(package, '.'); + if (!dot) { + break; + } + *dot = '\0'; + if (!cbm_ht_has(namespace_map, package)) { + continue; + } + const char *remainder = full + strlen(package) + 1; + char top_level[CBM_SZ_256]; + size_t top_len = strcspn(remainder, "."); + if (top_len == 0 || top_len >= sizeof(top_level)) { + return NULL; + } + memcpy(top_level, remainder, top_len); + top_level[top_len] = '\0'; + /* Only a top-level symbol of a file declaring this package qualifies; + * a nested class or method of the same name is not importable by + * `pkg.Name` and an ambiguous split-package name yields no edge. */ + const cbm_gbuf_node_t *owner = scala_toplevel_lookup(scala_index, package, top_level); + if (!owner || !owner->qualified_name) { + return NULL; + } + if (wildcard || !strchr(remainder, '.')) { + return owner; + } + /* `import pkg.Owner.member`: the member must exist under the owner's + * QN. Binding the owner instead would mislabel the edge. */ + char member_qn[CBM_SZ_512]; + if (snprintf(member_qn, sizeof(member_qn), "%s.%s", owner->qualified_name, + remainder + top_len + 1) >= (int)sizeof(member_qn)) { + return NULL; + } + const cbm_gbuf_node_t *scoped = cbm_gbuf_find_by_qn(ctx->gbuf, member_qn); + if (scoped && scoped->label && import_targetable_label(scoped->label)) { + return scoped; + } + return NULL; + } + return NULL; +} + +/* Scala `package` clauses are resolved only by the fail-closed resolver above. + * Letting them feed the generic first-declaring-file walk would bind Java or + * Kotlin imports of a shared package prefix to an arbitrary Scala file. */ +static bool path_is_scala(const char *path) { + const char *base = path; + for (const char *p = base; *p; p++) { + if (*p == '/' || *p == '\\') { + base = p + SKIP_ONE; + } + } + return cbm_language_for_filename(base) == CBM_LANG_SCALA; +} + +static bool declaring_file_is_scala(const cbm_gbuf_node_t *node) { + return node && node->file_path && path_is_scala(node->file_path); +} + const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, - const CBMImport *imp, - CBMHashTable *namespace_map) { + CBMLanguage language, const CBMImport *imp, + CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index) { if (!ctx || !imp || !imp->module_path) { return NULL; } @@ -2226,6 +2373,10 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t } } + if (language == CBM_LANG_SCALA) { + return resolve_scala_namespace_import(ctx, source_file_qn, imp, namespace_map, scala_index); + } + /* Strategy 2: namespace map. `using App.Utils`, `import com.example.Foo`, * `use App\Utils\Helper` name a NAMESPACE (or a member of it) that the * path-based QN cannot express. Try the full module path and progressively @@ -2279,7 +2430,8 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t memcpy(qbuf, seg, len); qbuf[len] = '\0'; const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(ctx->gbuf, qbuf); - if (n && (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0)) { + if (n && (!source_file_qn || strcmp(n->qualified_name, source_file_qn) != 0) && + !declaring_file_is_scala(n)) { return n; } } @@ -2471,17 +2623,45 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t return NULL; } +/* ── File node package property ──────────────────────────────────── */ + +/* Persist a file's package clause on its File node so an incremental run + * can rebuild the namespace map and the Scala index for the files it does + * not re-extract (see incr_load_stored_namespaces). */ +static void file_node_set_package(const cbm_pipeline_ctx_t *ctx, const char *file_qn, + const char *namespace_name) { + cbm_gbuf_node_t *node = (cbm_gbuf_node_t *)cbm_gbuf_find_by_qn(ctx->gbuf, file_qn); + if (!node || !node->label || strcmp(node->label, "File") != 0) { + return; + } + /* Append to the existing object ({"extension":".scala"}); the previous + * generation's value is gone with the purge of a changed file, so a + * `package` key already present means this pass set it, or the file is + * one the pass does not re-extract. */ + const char *props = node->properties_json ? node->properties_json : "{}"; + if (strstr(props, "\"package\"")) { + return; + } + size_t props_len = strlen(props); + if (props_len < 2 || props[props_len - 1] != '}') { + return; + } + char escaped[PKGMAP_PATH_BUF]; + cbm_json_escape(escaped, sizeof(escaped), namespace_name); + char json[PKGMAP_PATH_BUF * 2]; + int n = snprintf(json, sizeof(json), "%.*s%s\"package\":\"%s\"}", (int)props_len - 1, props, + props_len > 2 ? "," : "", escaped); + if (n < 0 || n >= (int)sizeof(json)) { + return; + } + cbm_gbuf_node_set_properties_json(node, json); +} + /* ── Namespace map ───────────────────────────────────────────────── */ -/* The namespace names themselves, so a caller that has parked some results on - * disk can still contribute their namespaces (see - * cbm_result_spill_namespace). A file missing from this map does not fail to - * resolve -- it resolves DIFFERENTLY, through the looser fallback, which is why - * an incomplete map changed edge counts in both directions rather than only - * losing edges. */ -CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, - const char *const *namespaces, - const char *const *rels, int count) { +static CBMHashTable *namespace_map_build(const cbm_pipeline_ctx_t *ctx, + const char *const *namespaces, const char *const *rels, + int count) { CBMHashTable *map = NULL; for (int i = 0; i < count; i++) { const char *namespace_name = namespaces[i]; @@ -2494,14 +2674,15 @@ CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, return NULL; } } - char *file_qn = cbm_pipeline_fqn_compute(project_name, rels[i], "__file__"); + char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rels[i], "__file__"); if (!file_qn) { continue; } + file_node_set_package(ctx, file_qn, namespace_name); /* Normalize the namespace key to dot-separated form so it matches the * dot-normalized lookups in cbm_pipeline_resolve_import_node (PHP uses * '\\', some grammars '::' or '/'). */ - char *key = strdup(namespace_name); + char *key = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, namespace_name); if (!key) { free(file_qn); continue; @@ -2534,42 +2715,267 @@ CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, free(prev); /* old value string */ } } - free(key); /* our fresh strdup — never stored */ - free(file_qn); /* content copied into combined */ + cbm_free(CBM_MEM_CLASS_OTHER, key); /* our fresh copy — never stored */ + free(file_qn); /* content copied into combined */ } } return map; } -/* Convenience for callers whose results are all in memory (the sequential - * definitions pass). A caller that can SPILL must use the _names variant and - * fill the parked slots from cbm_result_spill_namespace, or its map silently - * loses those files. */ -CBMHashTable *cbm_pipeline_namespace_map_build(const char *project_name, - CBMFileResult *const *results, - const char *const *rels, int count) { - const char **names = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)count * sizeof(char *)); - if (!names) { - return NULL; - } - for (int i = 0; i < count; i++) { - names[i] = results[i] ? results[i]->namespace_name : NULL; - } - CBMHashTable *map = cbm_pipeline_namespace_map_build_names(project_name, names, rels, count); - cbm_free(CBM_MEM_CLASS_OTHER, names); - return map; -} - static void ns_map_free_entry(const char *key, void *value, void *ud) { (void)ud; - free((void *)key); /* strdup'd in cbm_pipeline_namespace_map_build */ + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); /* cbm_mem_strdup'd in namespace_map_build */ free(value); } -void cbm_pipeline_namespace_map_free(CBMHashTable *map) { +static void namespace_map_free(CBMHashTable *map) { if (!map) { return; } cbm_ht_foreach(map, ns_map_free_entry, NULL); cbm_ht_free(map); } + +/* ── Scala top-level index ───────────────────────────────────────── */ + +typedef struct { + char *namespace_name; /* dot-normalized package clause */ + char *module_qn; /* language-aware module QN of the declaring file */ +} scala_file_entry_t; + +typedef struct { + CBMHashTable *files; /* rel path -> scala_file_entry_t (build-time only) */ + cbm_scala_index_t *index; +} scala_index_build_t; + +/* The module QN a file's top-level symbols hang off. Directory-module + * languages (Java, Go) put `class Outer` in Outer.java at .Outer, so + * is the module and Outer's methods and nested types sit two segments + * below it; stem-module languages (Scala `object Foo` in Foo.scala is + * Foo.Foo) use the file stem. MUST match cbm_lang_module_is_dir() + * (internal/cbm/helpers.c). */ +static bool pkgmap_module_is_dir(const char *rel) { + const char *base = rel; + for (const char *p = rel; *p; p++) { + if (*p == '/' || *p == '\\') { + base = p + SKIP_ONE; + } + } + CBMLanguage lang = cbm_language_for_filename(base); + return lang == CBM_LANG_JAVA || lang == CBM_LANG_GO; +} + +/* Keep the two smallest File nodes of a package (see scala_package_files_t). */ +static void scala_index_note_package_file(cbm_scala_index_t *index, const char *package, + const cbm_gbuf_node_t *file_node) { + if (!index->packages) { + index->packages = cbm_ht_create(CBM_SZ_64); + if (!index->packages) { + return; + } + } + scala_package_files_t *pf = cbm_ht_get(index->packages, package); + if (!pf) { + pf = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*pf)); + char *key = pf ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, package) : NULL; + if (!key) { + cbm_free(CBM_MEM_CLASS_OTHER, pf); + return; + } + pf->first = file_node; + cbm_ht_set(index->packages, key, pf); /* index owns key + pf */ + return; + } + int c = strcmp(file_node->qualified_name, pf->first->qualified_name); + if (c < 0) { + pf->second = pf->first; + pf->first = file_node; + } else if (c > 0 && + (!pf->second || strcmp(file_node->qualified_name, pf->second->qualified_name) < 0)) { + pf->second = file_node; + } +} + +/* A node is top-level when its QN is exactly ., one segment + * below the declaring file's module. Nested classes and methods carry deeper + * QNs. Module and File nodes share those QN shapes but declare nothing + * importable; a File node of a declaring file is instead a candidate for the + * package's `import pkg._` target. */ +static void scala_index_visit_node(const cbm_gbuf_node_t *node, void *ud) { + scala_index_build_t *b = ud; + if (!node || !node->qualified_name || !node->file_path || !node->name || + !import_targetable_label(node->label) || strcmp(node->label, "Module") == 0) { + return; + } + const scala_file_entry_t *fe = + (const scala_file_entry_t *)cbm_ht_get(b->files, node->file_path); + if (!fe) { + return; + } + if (strcmp(node->label, "File") == 0) { + scala_index_note_package_file(b->index, fe->namespace_name, node); + return; + } + size_t mod_len = strlen(fe->module_qn); + const char *qn = node->qualified_name; + if (strncmp(qn, fe->module_qn, mod_len) != 0 || qn[mod_len] != '.' || !qn[mod_len + 1] || + strchr(qn + mod_len + 1, '.')) { + return; + } + const char *tail = qn + mod_len + 1; + char key[PKGMAP_PATH_BUF]; + if (snprintf(key, sizeof(key), "%s.%s", fe->namespace_name, tail) >= (int)sizeof(key)) { + return; + } + if (!b->index->symbols) { + b->index->symbols = cbm_ht_create(CBM_SZ_256); + if (!b->index->symbols) { + return; + } + } + const char *stored_key = cbm_ht_get_key(b->index->symbols, key); + if (!stored_key) { + char *owned = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, key); + if (owned) { + cbm_ht_set(b->index->symbols, owned, (void *)node); /* index owns key */ + } + return; + } + if (cbm_ht_get(b->index->symbols, stored_key) != node) { + cbm_ht_set(b->index->symbols, stored_key, (void *)&scala_toplevel_ambiguous); + } +} + +static void scala_index_free_file_entry(const char *key, void *value, void *ud) { + (void)key; /* borrowed from the caller's rels array */ + (void)ud; + scala_file_entry_t *fe = value; + cbm_free(CBM_MEM_CLASS_OTHER, fe->namespace_name); + free(fe->module_qn); /* malloc'd by cbm_pipeline_fqn_module_dir */ + cbm_free(CBM_MEM_CLASS_OTHER, fe); +} + +static void scala_index_free_symbol(const char *key, void *value, void *ud) { + (void)value; /* borrowed gbuf node or the ambiguity marker */ + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); /* cbm_mem_strdup'd in scala_index_visit_node */ +} + +static void scala_index_free_package(const char *key, void *value, void *ud) { + (void)ud; + cbm_free(CBM_MEM_CLASS_OTHER, value); /* File nodes are borrowed */ + cbm_free(CBM_MEM_CLASS_OTHER, (void *)key); +} + +static void scala_index_free(cbm_scala_index_t *index) { + if (!index) { + return; + } + if (index->symbols) { + cbm_ht_foreach(index->symbols, scala_index_free_symbol, NULL); + cbm_ht_free(index->symbols); + } + if (index->packages) { + cbm_ht_foreach(index->packages, scala_index_free_package, NULL); + cbm_ht_free(index->packages); + } + cbm_free(CBM_MEM_CLASS_OTHER, index); +} + +/* Every file declaring a namespace contributes, not only Scala ones: a Scala + * import routinely names a Java class of the same (mixed) package. */ +static cbm_scala_index_t *scala_index_build(const cbm_pipeline_ctx_t *ctx, + const char *const *namespaces, const char *const *rels, + int count) { + scala_index_build_t b = {0}; + b.index = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*b.index)); + if (!b.index) { + return NULL; + } + for (int i = 0; i < count; i++) { + if (!namespaces[i] || !namespaces[i][0] || !rels[i]) { + continue; + } + if (!b.files) { + b.files = cbm_ht_create(CBM_SZ_64); + if (!b.files) { + break; + } + } + scala_file_entry_t *fe = cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*fe)); + if (!fe) { + continue; + } + fe->namespace_name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, namespaces[i]); + fe->module_qn = + cbm_pipeline_fqn_module_dir(ctx->project_name, rels[i], pkgmap_module_is_dir(rels[i])); + if (!fe->namespace_name || !fe->module_qn) { + scala_index_free_file_entry(rels[i], fe, NULL); + continue; + } + for (char *p = fe->namespace_name; *p; p++) { + if (*p == '\\' || *p == ':' || *p == '/') { + *p = '.'; + } + } + void *prev = cbm_ht_set(b.files, rels[i], fe); + if (prev) { + scala_index_free_file_entry(rels[i], prev, NULL); + } + } + if (!b.files) { + scala_index_free(b.index); + return NULL; + } + cbm_gbuf_foreach_node(ctx->gbuf, scala_index_visit_node, &b); + cbm_ht_foreach(b.files, scala_index_free_file_entry, NULL); + cbm_ht_free(b.files); + return b.index; +} + +/* ── Import maps ─────────────────────────────────────────────────── */ + +void cbm_pipeline_import_maps_build(const cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, + int file_count, const char *const *namespaces, + CBMHashTable **namespace_map, cbm_scala_index_t **scala_index) { + *namespace_map = NULL; + *scala_index = NULL; + if (!ctx || !ctx->gbuf || !files || !namespaces) { + return; + } + /* The fresh files first, then the previous generation's clauses for the + * files this pass does not re-extract (incremental routes only; the + * caller guarantees the two sets are disjoint). */ + int stored = ctx->stored_namespaces ? ctx->stored_namespace_count : 0; + size_t total = (size_t)file_count + (size_t)stored; + const char **all_ns = cbm_calloc(CBM_MEM_CLASS_OTHER, (total ? total : 1) * sizeof(char *)); + const char **all_rels = cbm_calloc(CBM_MEM_CLASS_OTHER, (total ? total : 1) * sizeof(char *)); + if (!all_ns || !all_rels) { + cbm_free(CBM_MEM_CLASS_OTHER, all_ns); + cbm_free(CBM_MEM_CLASS_OTHER, all_rels); + return; + } + bool scala_importer = false; + for (int i = 0; i < file_count; i++) { + all_ns[i] = namespaces[i]; + all_rels[i] = files[i].rel_path; + scala_importer = scala_importer || files[i].language == CBM_LANG_SCALA; + } + for (int i = 0; i < stored; i++) { + all_ns[file_count + i] = ctx->stored_namespaces[i].namespace_name; + all_rels[file_count + i] = ctx->stored_namespaces[i].rel_path; + } + *namespace_map = namespace_map_build(ctx, all_ns, all_rels, (int)total); + /* Only the Scala resolver reads the index; a corpus without a Scala + * importer pays nothing for it. */ + if (scala_importer) { + *scala_index = scala_index_build(ctx, all_ns, all_rels, (int)total); + } + cbm_free(CBM_MEM_CLASS_OTHER, all_ns); + cbm_free(CBM_MEM_CLASS_OTHER, all_rels); +} + +void cbm_pipeline_import_maps_free(CBMHashTable *namespace_map, cbm_scala_index_t *scala_index) { + scala_index_free(scala_index); + namespace_map_free(namespace_map); +} diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 99195cc2c0..507b25b48d 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1959,6 +1959,88 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p return 1; } +/* ── Stored package clauses ────────────────────────────────────── + * + * Both incremental routes hand the passes only the changed files, and the + * passes build the import maps (namespace map, Scala top-level index) from + * the files they are handed. The maps therefore knew only the changed files' + * packages: an import into an unchanged package resolved through the looser + * symbol fallback, or with the fail-closed Scala resolver not at all, until + * the next full index. The definitions/registry pass persists each file's + * clause on its File node (`package` property); read those back for every + * file that stays, so the passes see the whole project as a full build does. + * `skip` holds the rel paths whose rows are still present but about to be + * replaced or removed (legacy route); NULL once the purge already ran. */ +static void incr_free_stored_namespaces(cbm_file_namespace_t *ns, int count) { + for (int i = 0; i < count; i++) { + cbm_free(CBM_MEM_CLASS_OTHER, ns[i].rel_path); + cbm_free(CBM_MEM_CLASS_OTHER, ns[i].namespace_name); + } + cbm_free(CBM_MEM_CLASS_OTHER, ns); +} + +static int incr_load_stored_namespaces(cbm_store_t *store, const char *project, + const CBMHashTable *skip, cbm_file_namespace_t **out, + int *out_count) { + *out = NULL; + *out_count = 0; + sqlite3 *db = cbm_store_get_db(store); + if (!db) { + return -1; + } + /* LIKE keeps the JSON parse off the File nodes without a clause; + * json_valid() guards it because json_extract() raises on malformed + * properties (same shape as the store's FTS body expression). */ + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(db, + "SELECT file_path, json_extract(properties, '$.package') FROM nodes" + " WHERE project = ?1 AND label = 'File'" + " AND properties LIKE '%\"package\"%' AND json_valid(properties)", + CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { + return -1; + } + sqlite3_bind_text(stmt, 1, project, CBM_NOT_FOUND, SQLITE_TRANSIENT); + cbm_file_namespace_t *rows = NULL; + int count = 0; + int cap = 0; + int step_rc; + while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) { + const char *rel = (const char *)sqlite3_column_text(stmt, 0); + const char *ns = (const char *)sqlite3_column_text(stmt, 1); + if (!rel || !rel[0] || !ns || !ns[0] || (skip && cbm_ht_has(skip, rel))) { + continue; + } + if (count == cap) { + int new_cap = cap ? cap * 2 : CBM_SZ_64; + cbm_file_namespace_t *grown = cbm_realloc( + CBM_MEM_CLASS_OTHER, rows, (size_t)new_cap * sizeof(cbm_file_namespace_t)); + if (!grown) { + step_rc = SQLITE_NOMEM; + break; + } + rows = grown; + cap = new_cap; + } + rows[count].rel_path = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, rel); + rows[count].namespace_name = cbm_mem_strdup(CBM_MEM_CLASS_OTHER, ns); + if (!rows[count].rel_path || !rows[count].namespace_name) { + cbm_free(CBM_MEM_CLASS_OTHER, rows[count].rel_path); + cbm_free(CBM_MEM_CLASS_OTHER, rows[count].namespace_name); + step_rc = SQLITE_NOMEM; + break; + } + count++; + } + sqlite3_finalize(stmt); + if (step_rc != SQLITE_DONE) { + incr_free_stored_namespaces(rows, count); + return -1; + } + *out = rows; + *out_count = count; + return 0; +} + /* ── Delta-repair orchestration (closure route) ────────────────── * * The closure route's executor: clone the live generation, repair the @@ -2167,6 +2249,16 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char cbm_pipeline_get_excluded(p, &excluded_dirs, &excluded_count); path_aliases = cbm_load_path_aliases_excluded(cbm_pipeline_repo_path(p), excluded_dirs, excluded_count); + /* The purge already removed the closure and deleted files, so every File + * row left in the stage is a file the passes will not see. A failed read + * is a delta-route failure: the maps would silently diverge from a full + * build. */ + cbm_file_namespace_t *stored_ns = NULL; + int stored_ns_count = 0; + if (incr_load_stored_namespaces(staging, project, NULL, &stored_ns, &stored_ns_count) != 0) { + cbm_log_error("delta.err", "phase", "stored_namespaces"); + goto out; + } cbm_pipeline_ctx_t ctx = { .project_name = project, .repo_path = cbm_pipeline_repo_path(p), @@ -2178,6 +2270,8 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char .path_aliases = path_aliases, .excluded_dirs = excluded_dirs, .excluded_count = excluded_count, + .stored_namespaces = stored_ns, + .stored_namespace_count = stored_ns_count, }; for (int i = 0; i < ci; i++) { char *file_qn = cbm_pipeline_fqn_compute(project, changed_files[i].rel_path, "__file__"); @@ -2213,6 +2307,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char if (ctx.macro_table) { free((void *)ctx.macro_table); } + incr_free_stored_namespaces(stored_ns, stored_ns_count); cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); cbm_pipeline_set_pkgmap(NULL); if (phase_rc != 0) { @@ -2650,27 +2745,41 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil char *saved_adr = NULL; cbm_adr_t existing_adr = {0}; int adr_rc = cbm_store_adr_get(store, project, &existing_adr); + bool abort_preserve = false; if (adr_rc == CBM_STORE_OK) { bool had_adr_content = existing_adr.content != NULL; if (had_adr_content) { saved_adr = strdup(existing_adr.content); } cbm_store_adr_free(&existing_adr); - if (had_adr_content && !saved_adr) { - cbm_gbuf_free(existing); - free(changed_files); - for (int i = 0; i < deleted_count; i++) { - free(deleted[i]); - } - free(deleted); - free_mode_skipped(mode_skipped, mode_skipped_count); - cbm_store_free_coverage(old_cov, old_cov_count); - cbm_store_close(store); - closure_plan_free(&closure_plan); - return CBM_PIPELINE_ABORT_PRESERVE_DB; - } + abort_preserve = had_adr_content && !saved_adr; } else if (adr_rc != CBM_STORE_NOT_FOUND) { cbm_store_adr_free(&existing_adr); + abort_preserve = true; + } + + /* Package clauses of the files that stay: the changed and deleted files + * are still in the database here, so they are skipped explicitly. */ + cbm_file_namespace_t *stored_ns = NULL; + int stored_ns_count = 0; + if (!abort_preserve) { + CBMHashTable *skip = cbm_ht_create((size_t)(ci + deleted_count) * PAIR_LEN + CBM_SZ_64); + for (int i = 0; skip && i < ci; i++) { + cbm_ht_set(skip, changed_files[i].rel_path, &changed_files[i]); + } + for (int i = 0; skip && i < deleted_count; i++) { + cbm_ht_set(skip, deleted[i], deleted[i]); + } + int ns_rc = + skip ? incr_load_stored_namespaces(store, project, skip, &stored_ns, &stored_ns_count) + : -1; + cbm_ht_free(skip); /* keys borrowed */ + if (ns_rc != 0) { + cbm_log_error("incremental.err", "msg", "stored_namespaces_failed"); + abort_preserve = true; + } + } + if (abort_preserve) { cbm_gbuf_free(existing); free(changed_files); for (int i = 0; i < deleted_count; i++) { @@ -2681,6 +2790,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil cbm_store_free_coverage(old_cov, old_cov_count); cbm_store_close(store); closure_plan_free(&closure_plan); + free(saved_adr); return CBM_PIPELINE_ABORT_PRESERVE_DB; } @@ -2751,6 +2861,8 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .path_aliases = path_aliases, .excluded_dirs = excluded_dirs, .excluded_count = excluded_count, + .stored_namespaces = stored_ns, + .stored_namespace_count = stored_ns_count, }; for (int i = 0; i < ci; i++) { @@ -2794,6 +2906,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil free((void *)ctx.macro_table); ctx.macro_table = NULL; } + incr_free_stored_namespaces(stored_ns, stored_ns_count); + ctx.stored_namespaces = NULL; + ctx.stored_namespace_count = 0; /* Parallel extraction builds the process-global package map. Match the * full pipeline's ownership boundary on both success and failure. */ diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 7f266eae98..231067fb0d 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -84,6 +84,16 @@ typedef struct { void cbm_pkg_entries_init(cbm_pkg_entries_t *e); void cbm_pkg_entries_free(cbm_pkg_entries_t *e); +/* A file's declared namespace/package clause, as persisted on its File node + * (`package` property) by the previous index generation. */ +typedef struct { + char *rel_path; /* heap */ + char *namespace_name; /* heap, raw (un-normalized) clause */ +} cbm_file_namespace_t; + +/* Scala top-level index (pass_pkgmap.c); see cbm_pipeline_import_maps_build. */ +typedef struct cbm_scala_index cbm_scala_index_t; + /* Shared context passed to each pass function. * Derived from cbm_pipeline_t fields during run. */ typedef struct { @@ -113,6 +123,16 @@ typedef struct { char **excluded_dirs; int excluded_count; + /* Package clauses of the files this run does NOT re-extract, read back + * from their File nodes by the incremental routes. The import maps + * (namespace map, Scala index) are built from the files handed to a pass, + * and an incremental pass receives only the changed files; without these + * the maps would know only the changed files' packages and an import into + * an unchanged package would resolve differently than in a full build. + * NULL/0 on a full build. Owned by pipeline_incremental.c. */ + const cbm_file_namespace_t *stored_namespaces; + int stored_namespace_count; + /* Sequential cross-LSP registry arena. The lsp_cross pass builds its * shared per-language registries here; resolved_calls entries may BORROW * strings owned by these registries, and the later calls pass still @@ -235,35 +255,48 @@ bool cbm_pipeline_http_client_call_url(const cbm_gbuf_t *gbuf, const char *proje * 1. Module-path resolution (relative / pkgmap / fqn_module) → existing node. * This preserves the behavior for Python/TS/Go whose module path maps * directly to a sibling Module/File QN. - * 2. namespace_map[module_path-prefix] → File node QN (Java/Kotlin/C#/PHP + * 2. namespace_map[module_path-prefix] → File/symbol node QN (Java/Kotlin/Scala/C#/PHP * `using`/`import` of a NAMESPACE that the path-based QN cannot express). * 3. Symbol-name fallback: the import's last path segment matched against an * in-graph definition node of the same simple name in a different file * (Rust `use crate::util::helper`, Java `import com.example.Util`, ...). * - * `namespace_map` may be NULL (skips step 2). `source_file_qn` is the importing - * file's __file__ QN, used to avoid self-imports in step 3. */ + * `namespace_map` may be NULL (skips step 2). `scala_index` is the Scala + * top-level index from cbm_pipeline_import_maps_build (NULL when no Scala + * file is among the importers). `source_file_qn` is the importing file's + * __file__ QN, used to avoid self-imports in step 3. */ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *ctx, const char *source_rel, const char *source_file_qn, - const CBMImport *imp, - CBMHashTable *namespace_map); - -/* Build a namespace → File-node-QN map from a set of extraction results. - * Each result that declared a namespace/package contributes one entry keyed by - * the namespace string (e.g. "App.Utils", "com.example"). Returns NULL when no - * results declared a namespace. Caller frees via cbm_pipeline_namespace_map_free. */ -CBMHashTable *cbm_pipeline_namespace_map_build(const char *project_name, - CBMFileResult *const *results, - const char *const *rels, int count); -/* The same map built from the namespace names directly. The parallel pass needs - * this: results it has spilled are NULL in its cache, and a file missing from - * the map does not fail to resolve -- it resolves through the looser fallback, - * so an incomplete map CHANGES the graph instead of shrinking it. */ -CBMHashTable *cbm_pipeline_namespace_map_build_names(const char *project_name, - const char *const *namespaces, - const char *const *rels, int count); -void cbm_pipeline_namespace_map_free(CBMHashTable *map); + CBMLanguage language, const CBMImport *imp, + CBMHashTable *namespace_map, + const cbm_scala_index_t *scala_index); + +/* The import-resolution maps of one definitions/registry pass, built after + * all definition nodes are in the graph buffer: + * + * namespace_map: namespace → '\n'-separated File-node QNs of every file + * declaring it (e.g. "App.Utils", "com.example"), for C# `using`, Java/ + * Kotlin `import`, PHP `use`. NULL when no file declares a namespace. + * scala_index: "." → the node declaring a top-level `Name` + * in a file whose package clause is ``, plus the package's + * wildcard target, so the Scala resolver does every step in O(1). A name + * declared at top level by two files of a split package is recorded as + * ambiguous and never binds. NULL unless a Scala file is among `files` + * (only the Scala resolver reads it). + * + * `namespaces[i]` is the clause of files[i] or NULL — a caller that spilled + * results must still supply it (cbm_result_spill_namespace): a file missing + * from the map does not fail to resolve, it resolves through the looser + * fallback, so an incomplete map CHANGES the graph instead of shrinking it. + * ctx->stored_namespaces complete the maps on an incremental run. Each + * declaring file's clause is also recorded as the `package` property of its + * File node, which is where a later incremental run reads it from. Frees via + * cbm_pipeline_import_maps_free. */ +void cbm_pipeline_import_maps_build(const cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, + int file_count, const char *const *namespaces, + CBMHashTable **namespace_map, cbm_scala_index_t **scala_index); +void cbm_pipeline_import_maps_free(CBMHashTable *namespace_map, cbm_scala_index_t *scala_index); /* Parse a manifest file and collect pkg entries. Returns true if basename matched. */ bool cbm_pkgmap_try_parse(const char *basename, const char *rel_path, const char *source, diff --git a/tests/test_edge_imports.c b/tests/test_edge_imports.c index 01d4419c1b..e93fff5b0e 100644 --- a/tests/test_edge_imports.c +++ b/tests/test_edge_imports.c @@ -50,6 +50,7 @@ #include #include #include +#include #include #include @@ -138,6 +139,28 @@ static cbm_store_t *ei_index_files(EILangProj *lp, const EILangFile *files, int return cbm_store_open_path(lp->dbpath); } +/* Rewrite one already-indexed fixture file and index the same repository + * again, closing `store` first. One changed file and nothing new keeps the + * second run on an incremental route. Returns the reopened store. */ +static cbm_store_t *ei_rewrite_and_reindex(EILangProj *lp, cbm_store_t *store, + const EILangFile *file) { + cbm_store_close(store); + char path[700]; + snprintf(path, sizeof(path), "%s/%s", lp->tmpdir, file->name); + FILE *f = fopen(path, "wb"); + if (!f) + return NULL; + fputs(file->content, f); + fclose(f); + + char args[700]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", lp->tmpdir); + char *resp = cbm_mcp_handle_tool(lp->srv, "index_repository", args); + if (resp) + free(resp); + return cbm_store_open_path(lp->dbpath); +} + static char *ei_slurp_file(const char *path) { FILE *f = fopen(path, "rb"); if (!f) { @@ -813,6 +836,433 @@ TEST(ei_cpp_header_include_targets_header_file) { PASS(); } +/* Scala packages do not have to mirror the repository path. Concrete imports + * must resolve to the symbol declared in the source package, while package + * wildcards may only resolve inside that exact package. External wildcards + * must fail closed instead of selecting an unrelated same-name node. */ +TEST(ei_scala_package_imports_ignore_physical_layout) { + static const EILangFile f[] = { + {"modules/model/src/odd/layout/Target.scala", + "package com.example.model\nobject Target { def build(): Int = 1 }\n"}, + {"modules/model/src/another/layout/Aux.scala", + "package com.example.model\nobject Aux { def value: Int = 2 }\n"}, + {"modules/decoy/src/App.scala", "package unrelated\nobject App\n"}, + {"modules/client/src/Consumer.scala", + "package com.example.client\n" + "import com.example.model.Target\n" + "import com.example.model._\n" + "import outside.library._\n" + "object Consumer { def run(): Int = Target.build() }\n"}}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 4); + ASSERT_NOT_NULL(store); + int64_t source_id = + ei_node_id_for_file_label(store, lp.project, "modules/client/src/Consumer.scala", "File"); + ASSERT_GT(source_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 2); + + bool saw_target_symbol = false; + for (int i = 0; i < edge_count; i++) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + ASSERT_NOT_NULL(target); + ASSERT_EQ(cbm_store_find_node_by_id(store, edges[i].target_id, target), CBM_STORE_OK); + ASSERT_NOT_NULL(target->file_path); + ASSERT_TRUE(strstr(target->file_path, "modules/model/src/") == target->file_path); + if (target->name && strcmp(target->name, "Target") == 0 && + strstr(target->file_path, "Target.scala") != NULL) { + saw_target_symbol = true; + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + ASSERT_TRUE(saw_target_symbol); + ei_cleanup(&lp, store); + PASS(); +} + +/* Scala package clauses feed only the Scala resolver. A Java import whose + * package is external must not bind to a Scala file that merely declares a + * shared prefix of that package. */ +TEST(ei_java_import_ignores_scala_package_prefix) { + static const EILangFile f[] = { + {"core/src/main/scala/com/acme/package.scala", + "package com.acme\npackage object core { val marker: Int = 1 }\n"}, + {"client/src/main/java/com/acme/client/Consumer.java", + "package com.acme.client;\n" + "import com.acme.util.Future;\n" + "public class Consumer { Future pending; }\n"}}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 2); + ASSERT_NOT_NULL(store); + int64_t source_id = ei_node_id_for_file_label( + store, lp.project, "client/src/main/java/com/acme/client/Consumer.java", "File"); + ASSERT_GT(source_id, 0); + + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 0); + cbm_store_free_edges(edges, edge_count); + ei_cleanup(&lp, store); + PASS(); +} + +/* Index `files`, return the IMPORTS edges leaving `source_file` and, when + * exactly one is expected, the name and file of its target. */ +static int ei_scala_single_import_target(const EILangFile *files, int nfiles, + const char *source_file, char *target_name, size_t name_sz, + char *target_file, size_t file_sz) { + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, files, nfiles); + if (!store) { + return -1; + } + int64_t source_id = ei_node_id_for_file_label(store, lp.project, source_file, "File"); + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (source_id <= 0 || cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, + &edge_count) != CBM_STORE_OK) { + ei_cleanup(&lp, store); + return -1; + } + if (edge_count == 1) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + if (target && + cbm_store_find_node_by_id(store, edges[0].target_id, target) == CBM_STORE_OK) { + snprintf(target_name, name_sz, "%s", target->name ? target->name : ""); + snprintf(target_file, file_sz, "%s", target->file_path ? target->file_path : ""); + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + ei_cleanup(&lp, store); + return edge_count; +} + +/* The most common mixed-package case: a Scala file imports a Java class that + * lives in the same source package but a different source root. */ +TEST(ei_scala_imports_java_class_from_mixed_package) { + static const EILangFile f[] = { + {"core/src/main/java/com/acme/core/Codec.java", + "package com.acme.core;\npublic class Codec { public static int size() { return 1; } }\n"}, + {"core/src/main/scala/com/acme/core/Util.scala", "package com.acme.core\nobject Util\n"}, + {"client/src/main/scala/com/acme/client/Consumer.scala", + "package com.acme.client\n" + "import com.acme.core.Codec\n" + "object Consumer { def run(): Int = Codec.size() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, + "client/src/main/scala/com/acme/client/Consumer.scala", + name, sizeof(name), file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Codec"); + ASSERT_TRUE(strstr(file, "Codec.java") != NULL); + PASS(); +} + +/* A package declared by both Java and Scala files: a Java import still binds + * the Java declaring file exactly as on main, never a Scala sibling. */ +TEST(ei_java_import_of_mixed_package_binds_java_file) { + static const EILangFile f[] = {{"a/src/main/scala/com/acme/shared/Helper.scala", + "package com.acme.shared\nobject Helper\n"}, + {"b/src/main/java/com/acme/shared/Shared.java", + "package com.acme.shared;\npublic class Shared {}\n"}, + {"c/src/main/java/com/acme/app/App.java", + "package com.acme.app;\nimport com.acme.shared.Shared;\npublic " + "class App { Shared s; }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "c/src/main/java/com/acme/app/App.java", name, + sizeof(name), file, sizeof(file)), + 1); + ASSERT_TRUE(strstr(file, "Shared.java") != NULL); + PASS(); +} + +/* Two files of a split package both declare a top-level `Codec`: the import is + * ambiguous and must produce no edge rather than pick one. */ +TEST(ei_scala_split_package_same_name_top_level_is_ambiguous) { + static const EILangFile f[] = {{"main/src/com/acme/model/Codec.scala", + "package com.acme.model\nobject Codec { def a(): Int = 1 }\n"}, + {"bench/src/com/acme/model/Codec.scala", + "package com.acme.model\nobject Codec { def b(): Int = 2 }\n"}, + {"client/src/Consumer.scala", + "package com.acme.client\n" + "import com.acme.model.Codec\n" + "object Consumer { def run(): Int = Codec.a() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 0); + PASS(); +} + +/* `import pkg.Name` matches only a top-level Name. A nested object of the same + * name neither becomes the target nor makes the real top-level one ambiguous. */ +TEST(ei_scala_import_ignores_nested_same_name) { + static const EILangFile f[] = { + {"model/src/Codec.scala", "package com.acme.model\nobject Codec { def a(): Int = 1 }\n"}, + {"model/src/Outer.scala", + "package com.acme.model\nobject Outer { object Codec { def b(): Int = 2 } }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Codec\n" + "object Consumer { def run(): Int = Codec.a() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Codec"); + ASSERT_TRUE(strstr(file, "model/src/Codec.scala") != NULL); + PASS(); +} + +/* `import pkg.Owner.member` binds the member under the owner's QN. When the + * member does not exist the import fails closed: no IMPORTS edge, and the bare + * `missing()` call must not become a CALLS edge to the `Config` class. */ +TEST(ei_scala_unresolvable_member_import_yields_no_owner_edge) { + static const EILangFile f[] = { + {"model/src/Config.scala", + "package com.acme.model\nobject Config { def load(): Int = 1 }\n"}, + {"client/src/Loader.scala", "package com.acme.client\n" + "import com.acme.model.Config.load\n" + "object Loader { def run(): Int = load() }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Config.missing\n" + "object Consumer { def run(): Int = missing() }\n"}}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 3); + ASSERT_NOT_NULL(store); + + int64_t loader_id = + ei_node_id_for_file_label(store, lp.project, "client/src/Loader.scala", "File"); + ASSERT_GT(loader_id, 0); + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_source_type(store, loader_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 1); + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + ASSERT_NOT_NULL(target); + ASSERT_EQ(cbm_store_find_node_by_id(store, edges[0].target_id, target), CBM_STORE_OK); + ASSERT_STR_EQ(target->name, "load"); + cbm_store_free_nodes(target, 1); + cbm_store_free_edges(edges, edge_count); + + int64_t consumer_id = + ei_node_id_for_file_label(store, lp.project, "client/src/Consumer.scala", "File"); + ASSERT_GT(consumer_id, 0); + edges = NULL; + edge_count = 0; + ASSERT_EQ( + cbm_store_find_edges_by_source_type(store, consumer_id, "IMPORTS", &edges, &edge_count), + CBM_STORE_OK); + ASSERT_EQ(edge_count, 0); + cbm_store_free_edges(edges, edge_count); + + cbm_node_t *configs = NULL; + int config_count = 0; + ASSERT_EQ(cbm_store_find_nodes_by_name(store, lp.project, "Config", &configs, &config_count), + CBM_STORE_OK); + ASSERT_GT(config_count, 0); + for (int i = 0; i < config_count; i++) { + cbm_edge_t *calls = NULL; + int call_count = 0; + ASSERT_EQ( + cbm_store_find_edges_by_target_type(store, configs[i].id, "CALLS", &calls, &call_count), + CBM_STORE_OK); + ASSERT_EQ(call_count, 0); + cbm_store_free_edges(calls, call_count); + } + cbm_store_free_nodes(configs, config_count); + ei_cleanup(&lp, store); + PASS(); +} + +/* Consecutive `package a` / `package b` clauses declare a.b. */ +TEST(ei_scala_chained_package_clauses_resolve) { + static const EILangFile f[] = { + {"model/src/Target.scala", + "package com.acme\npackage model\nobject Target { def build(): Int = 1 }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Target\n" + "object Consumer { def run(): Int = Target.build() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 2, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Target"); + ASSERT_TRUE(strstr(file, "Target.scala") != NULL); + PASS(); +} + +/* Java class QNs are directory-based (`Request` in Request.java is + * .Request), so its nested `Builder` sits one segment below the stem + * module QN. It is not a top-level symbol of the package and must neither + * bind `import pkg.Builder` nor make the real top-level Scala `Builder` + * ambiguous. */ +TEST(ei_scala_import_ignores_java_nested_type_of_same_name) { + static const EILangFile f[] = { + {"core/src/main/java/com/acme/core/Request.java", + "package com.acme.core;\n" + "public class Request {\n" + " public static class Builder { public Request build() { return null; } }\n" + "}\n"}, + {"core/src/main/scala/com/acme/core/Builder.scala", + "package com.acme.core\nobject Builder { def make(): Int = 1 }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.core.Builder\n" + "object Consumer { def run(): Int = Builder.make() }\n"}}; + char name[64] = ""; + char file[256] = ""; + ASSERT_EQ(ei_scala_single_import_target(f, 3, "client/src/Consumer.scala", name, sizeof(name), + file, sizeof(file)), + 1); + ASSERT_STR_EQ(name, "Builder"); + ASSERT_TRUE(strstr(file, "Builder.scala") != NULL); + PASS(); +} + +/* The Scala IMPORTS of client/src/Consumer.scala in the incremental fixture + * below: exactly the symbol import of `Target` and the wildcard bound to the + * package's smallest declaring file, plus the `run() -> build()` CALLS edge + * the import map yields. */ +static int ei_scala_incr_consumer_edges_ok(cbm_store_t *store, const char *project) { + int64_t source_id = + ei_node_id_for_file_label(store, project, "client/src/Consumer.scala", "File"); + if (source_id <= 0) { + fprintf(stderr, " no File node for Consumer.scala\n"); + return 0; + } + cbm_edge_t *edges = NULL; + int edge_count = 0; + if (cbm_store_find_edges_by_source_type(store, source_id, "IMPORTS", &edges, &edge_count) != + CBM_STORE_OK) { + return 0; + } + bool saw_target = false; + bool saw_wildcard = false; + for (int i = 0; i < edge_count; i++) { + cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + if (!target || + cbm_store_find_node_by_id(store, edges[i].target_id, target) != CBM_STORE_OK) { + free(target); + continue; + } + if (target->name && strcmp(target->name, "Target") == 0 && target->file_path && + strstr(target->file_path, "model/src/Target.scala")) { + saw_target = true; + } else if (target->label && strcmp(target->label, "File") == 0 && target->file_path && + strcmp(target->file_path, "model/src/Aux.scala") == 0) { + saw_wildcard = true; + } + cbm_store_free_nodes(target, 1); + } + cbm_store_free_edges(edges, edge_count); + if (edge_count != 2 || !saw_target || !saw_wildcard) { + fprintf(stderr, " Consumer IMPORTS: count=%d target=%d wildcard=%d\n", edge_count, + saw_target, saw_wildcard); + return 0; + } + + cbm_node_t *runs = NULL; + int run_count = 0; + if (cbm_store_find_nodes_by_name(store, project, "run", &runs, &run_count) != CBM_STORE_OK) { + return 0; + } + bool saw_call = false; + for (int i = 0; i < run_count && !saw_call; i++) { + cbm_edge_t *calls = NULL; + int call_count = 0; + if (cbm_store_find_edges_by_source_type(store, runs[i].id, "CALLS", &calls, &call_count) != + CBM_STORE_OK) { + continue; + } + for (int j = 0; j < call_count && !saw_call; j++) { + cbm_node_t *callee = (cbm_node_t *)calloc(1, sizeof(cbm_node_t)); + if (callee && + cbm_store_find_node_by_id(store, calls[j].target_id, callee) == CBM_STORE_OK && + callee->name && strcmp(callee->name, "build") == 0) { + saw_call = true; + } + if (callee) { + cbm_store_free_nodes(callee, 1); + } + } + cbm_store_free_edges(calls, call_count); + } + cbm_store_free_nodes(runs, run_count); + if (!saw_call) { + fprintf(stderr, " no CALLS run -> build\n"); + } + return saw_call; +} + +/* Graph content must not depend on whether the last index was full or + * incremental. An incremental run hands the passes only the changed file, so + * the namespace map and the Scala index must be completed from the package + * clauses persisted for the unchanged files; otherwise the fail-closed Scala + * resolver drops every import into an unchanged package, and the CALLS built + * on them, until the next full index. Exercised on both incremental routes + * (closure repair, then the legacy partial fallback), each proven by the + * route probe so a silent fall-back to a full reindex cannot pass this. */ +TEST(ei_scala_incremental_reindex_keeps_imports_into_unchanged_packages) { + static const EILangFile f[] = { + {"model/src/Target.scala", + "package com.acme.model\nobject Target { def build(): Int = 1 }\n"}, + {"model/src/Aux.scala", "package com.acme.model\nobject Aux { def value: Int = 2 }\n"}, + {"client/src/Consumer.scala", "package com.acme.client\n" + "import com.acme.model.Target\n" + "import com.acme.model._\n" + "object Consumer { def run(): Int = Target.build() }\n"}}; + /* Body-only edits: a new definition name makes the closure route decline + * (another file might reference it), which would fall back to a full + * reindex and void the route proof below. */ + static const EILangFile edit1 = {"client/src/Consumer.scala", + "package com.acme.client\n" + "import com.acme.model.Target\n" + "import com.acme.model._\n" + "object Consumer { def run(): Int = Target.build() + 1 }\n"}; + static const EILangFile edit2 = {"client/src/Consumer.scala", + "package com.acme.client\n" + "import com.acme.model.Target\n" + "import com.acme.model._\n" + "object Consumer { def run(): Int = Target.build() + 2 }\n"}; + + EILangProj lp; + cbm_store_t *store = ei_index_files(&lp, f, 3); + ASSERT_NOT_NULL(store); + ASSERT_TRUE(ei_scala_incr_consumer_edges_ok(store, lp.project)); + + store = ei_rewrite_and_reindex(&lp, store, &edit1); + ASSERT_NOT_NULL(store); + ASSERT_EQ((int)cbm_pipeline_incremental_test_last_route(), + (int)CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_TRUE(ei_scala_incr_consumer_edges_ok(store, lp.project)); + + cbm_pipeline_incremental_test_force_legacy_partial_once(); + store = ei_rewrite_and_reindex(&lp, store, &edit2); + ASSERT_NOT_NULL(store); + ASSERT_EQ((int)cbm_pipeline_incremental_test_last_route(), + (int)CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); + ASSERT_TRUE(ei_scala_incr_consumer_edges_ok(store, lp.project)); + + ei_cleanup(&lp, store); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════════════ * RED REPRODUCTION — Rust * @@ -1469,6 +1919,18 @@ SUITE(edge_imports) { RUN_TEST(ei_py_external_import_never_binds_project_symbol); RUN_TEST(ei_cpp_header_include_targets_header_file); + /* ── GREEN GUARDS — Scala package-aware resolution ── */ + RUN_TEST(ei_scala_package_imports_ignore_physical_layout); + RUN_TEST(ei_java_import_ignores_scala_package_prefix); + RUN_TEST(ei_scala_imports_java_class_from_mixed_package); + RUN_TEST(ei_java_import_of_mixed_package_binds_java_file); + RUN_TEST(ei_scala_split_package_same_name_top_level_is_ambiguous); + RUN_TEST(ei_scala_import_ignores_nested_same_name); + RUN_TEST(ei_scala_unresolvable_member_import_yields_no_owner_edge); + RUN_TEST(ei_scala_chained_package_clauses_resolve); + RUN_TEST(ei_scala_import_ignores_java_nested_type_of_same_name); + RUN_TEST(ei_scala_incremental_reindex_keeps_imports_into_unchanged_packages); + /* ── RED REPRODUCTIONS — Rust (expected to FAIL until pipeline fixed) ── */ RUN_TEST(ei_rust_mod_plus_use); RUN_TEST(ei_rust_use_crate_path); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index cde8aee452..0e459b7b31 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -58,6 +58,17 @@ static int __attribute__((unused)) has_import(CBMFileResult *r, const char *path return 0; } +static const CBMImport *find_import(CBMFileResult *r, const char *local, const char *path) { + for (int i = 0; i < r->imports.count; i++) { + const CBMImport *imp = &r->imports.items[i]; + if (imp->local_name && imp->module_path && strcmp(imp->local_name, local) == 0 && + strcmp(imp->module_path, path) == 0) { + return imp; + } + } + return NULL; +} + /* Count definitions with a given label. */ /* Check for a definition with the given qualified name. Distinct from * find_def_by_name, which returns the first match by NAME and so cannot tell two @@ -5010,10 +5021,11 @@ TEST(swift_non_url_constructor_untouched_issue1892) { * the per-file constant map and resolved at the call site, for both return * statements and arrow expression bodies. */ TEST(extract_ts_await_generic_call_issue2210) { - CBMFileResult *r = extract("function parseJsonBody() { return {} as T; }\n" - "async function plain() { return await parseJsonBody(); }\n" - "async function generic() { return await parseJsonBody(); }\n", - CBM_LANG_TYPESCRIPT, "t", "await.ts"); + CBMFileResult *r = + extract("function parseJsonBody() { return {} as T; }\n" + "async function plain() { return await parseJsonBody(); }\n" + "async function generic() { return await parseJsonBody(); }\n", + CBM_LANG_TYPESCRIPT, "t", "await.ts"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); ASSERT_EQ(count_calls_named(r, "parseJsonBody"), 2); @@ -6342,6 +6354,65 @@ TEST(extract_ts_member_call_flags_is_method) { PASS(); } +TEST(extract_scala_import_selectors_and_aliases) { + CBMFileResult *r = extract("import foo.Direct\n" + "import foo.{Selected, Original => Alias, Hidden => _, _}\n" + "import modern.{Source as ModernAlias, *}\n" + "import alpha.One, beta.Two\n", + CBM_LANG_SCALA, "t", "Imports.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(find_import(r, "Direct", "foo.Direct")); + ASSERT_NOT_NULL(find_import(r, "Selected", "foo.Selected")); + ASSERT_NOT_NULL(find_import(r, "Alias", "foo.Original")); + ASSERT_NULL(find_import(r, "Hidden", "foo.Hidden")); + ASSERT_NOT_NULL(find_import(r, "*", "foo")); + ASSERT_NOT_NULL(find_import(r, "ModernAlias", "modern.Source")); + ASSERT_NOT_NULL(find_import(r, "*", "modern")); + ASSERT_NOT_NULL(find_import(r, "One", "alpha.One")); + ASSERT_NOT_NULL(find_import(r, "Two", "beta.Two")); + cbm_free_result(r); + PASS(); +} + +TEST(extract_scala_package_namespace) { + CBMFileResult *r = extract("package com.example.app.account\n" + "import com.example.app.model.Target\n" + "object Consumer\n", + CBM_LANG_SCALA, "t", "modules/client/Consumer.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(r->namespace_name); + ASSERT_STR_EQ(r->namespace_name, "com.example.app.account"); + cbm_free_result(r); + PASS(); +} + +TEST(extract_scala_chained_package_clauses) { + CBMFileResult *r = extract("package com.example\n" + "// chained clause below\n" + "package app.account\n" + "import com.example.app.model.Target\n" + "object Consumer\n", + CBM_LANG_SCALA, "t", "modules/client/Consumer.scala"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_NOT_NULL(r->namespace_name); + ASSERT_STR_EQ(r->namespace_name, "com.example.app.account"); + cbm_free_result(r); + + /* A braced clause scopes only its body; the file namespace stays the outer one. */ + r = extract("package com.example\n" + "package braced { object Inner }\n" + "object Outer\n", + CBM_LANG_SCALA, "t", "Outer.scala"); + ASSERT_NOT_NULL(r); + ASSERT_NOT_NULL(r->namespace_name); + ASSERT_STR_EQ(r->namespace_name, "com.example"); + 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. */ @@ -9238,6 +9309,9 @@ SUITE(extraction) { RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); RUN_TEST(extract_python_bare_call_flag_is_depth_independent); RUN_TEST(extract_ts_member_call_flags_is_method); + RUN_TEST(extract_scala_import_selectors_and_aliases); + RUN_TEST(extract_scala_package_namespace); + RUN_TEST(extract_scala_chained_package_clauses); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ed9b0dd3cb..dac29a5528 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1277,6 +1277,44 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch return cross_file_edge_exists(s, project, src_name, tgt_name, "CALLS"); } +static bool cross_file_call_to_owner_has_strategy(cbm_store_t *s, const char *project, + const char *src_name, const char *tgt_name, + const char *owner_fragment, + const char *strategy_fragment) { + cbm_node_t *srcs = NULL; + cbm_node_t *tgts = NULL; + int sc = 0; + int tc = 0; + cbm_store_find_nodes_by_name(s, project, src_name, &srcs, &sc); + cbm_store_find_nodes_by_name(s, project, tgt_name, &tgts, &tc); + bool found = false; + for (int i = 0; i < sc && !found; i++) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, srcs[i].id, "CALLS", &edges, &ec); + for (int j = 0; j < ec && !found; j++) { + for (int k = 0; k < tc; k++) { + if (edges[j].target_id == tgts[k].id && tgts[k].qualified_name && + strstr(tgts[k].qualified_name, owner_fragment) && edges[j].properties_json && + strstr(edges[j].properties_json, strategy_fragment)) { + found = true; + break; + } + } + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + if (srcs) { + cbm_store_free_nodes(srcs, sc); + } + if (tgts) { + cbm_store_free_nodes(tgts, tc); + } + return found; +} + /* True iff the exact named CALLS edge exists and its serialized strategy * contains `strategy_fragment`. Parallel synthetic-carrier regressions use * this on a separate ordinary-call control: it proves the cross-file LSP ran @@ -7218,6 +7256,179 @@ TEST(pipeline_go_bare_ref_never_binds_field_parallel) { PASS(); } +static void write_scala_import_alias_fixture(const char *tmp) { + write_temp_file(tmp, "alias_targets.scala", + "package alias_targets\n" + "object CorrectObject { def execute(): Int = 1 }\n" + "object WrongObject { def execute(): Int = 2 }\n" + "object ExternalStateRef { def make(): Int = 3 }\n"); + write_temp_file(tmp, "alias_caller.scala", + "package alias_caller\n" + "import alias_targets.{CorrectObject => ImportedAlias}\n" + "import outside.library.ExternalStateRef\n" + "object AliasCalls {\n" + " def invokeAlias(): Int = ImportedAlias.execute()\n" + " def invokeExternal(): Int = ExternalStateRef.make()\n" + "}\n"); +} + +static bool scala_import_alias_edge_is_exact(cbm_store_t *s, const char *project) { + cbm_edge_t *imports = NULL; + int import_count = 0; + bool exact_call = cross_file_call_to_owner_has_strategy(s, project, "invokeAlias", "execute", + "CorrectObject.execute", "import_map"); + cbm_store_find_edges_by_type(s, project, "IMPORTS", &imports, &import_count); + if (imports) { + cbm_store_free_edges(imports, import_count); + } + return exact_call && import_count == 1; +} + +TEST(pipeline_scala_import_alias_resolves_exact_method) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_alias_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_scala_import_alias_fixture(tmp); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_alias.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); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(scala_import_alias_edge_is_exact(s, cbm_pipeline_project_name(p))); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* `import pkg.Owner.member` when the package is split across modules and a + * second file in the same package declares a same-named member: the import + * must bind to Owner.member, not degrade to the owner (which would drop the + * unqualified `member()` call). */ +TEST(pipeline_scala_member_import_in_split_package_binds_owner_member) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_member_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "main_parser.scala", + "package split_pkg\n" + "object Parser { def check(s: String): Boolean = true }\n"); + write_temp_file(tmp, "bench_parser.scala", + "package split_pkg\n" + "class ParserBench { def check(s: String): Boolean = false }\n"); + write_temp_file(tmp, "member_caller.scala", + "package member_caller\n" + "import split_pkg.Parser.check\n" + "object Decoder { def validate(s: String): Boolean = check(s) }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_member.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); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(cross_file_call_to_owner_has_strategy(s, cbm_pipeline_project_name(p), "validate", + "check", "Parser.check", "import_map")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Only the Scala resolver reads the top-level index, so a pass whose files + * hold no Scala importer must not pay the node walk and per-symbol keys for + * it: Java/Kotlin/C#/PHP corpora get exactly the namespace map, as before. + * One Scala file among the importers builds it, whether or not that file + * declares a package (a default-package Scala file still imports). */ +TEST(pipeline_scala_index_built_only_for_scala_importers) { + cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); + ASSERT_NOT_NULL(gb); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "File", "Codec.java", "proj.core.Codec.__file__", + "core/Codec.java", 0, 0, "{}"), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "Class", "Codec", "proj.core.Codec", "core/Codec.java", 1, 3, + "{}"), + 0); + ASSERT_GT(cbm_gbuf_upsert_node(gb, "File", "App.scala", "proj.app.App.__file__", + "app/App.scala", 0, 0, "{}"), + 0); + atomic_int cancelled = 0; + cbm_pipeline_ctx_t ctx = { + .project_name = "proj", + .repo_path = "/tmp/proj", + .gbuf = gb, + .cancelled = &cancelled, + }; + + cbm_file_info_t java_only[] = {{.rel_path = "core/Codec.java", .language = CBM_LANG_JAVA}}; + const char *java_ns[] = {"com.acme.core"}; + CBMHashTable *namespace_map = NULL; + cbm_scala_index_t *scala_index = NULL; + cbm_pipeline_import_maps_build(&ctx, java_only, 1, java_ns, &namespace_map, &scala_index); + ASSERT_NOT_NULL(namespace_map); + ASSERT_NULL(scala_index); + cbm_pipeline_import_maps_free(namespace_map, scala_index); + + cbm_file_info_t mixed[] = {{.rel_path = "core/Codec.java", .language = CBM_LANG_JAVA}, + {.rel_path = "app/App.scala", .language = CBM_LANG_SCALA}}; + const char *mixed_ns[] = {"com.acme.core", NULL}; + cbm_pipeline_import_maps_build(&ctx, mixed, 2, mixed_ns, &namespace_map, &scala_index); + ASSERT_NOT_NULL(namespace_map); + ASSERT_NOT_NULL(scala_index); + cbm_pipeline_import_maps_free(namespace_map, scala_index); + + cbm_gbuf_free(gb); + PASS(); +} + +TEST(pipeline_scala_import_alias_parallel_resolves_exact_method) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_alias_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_scala_import_alias_fixture(tmp); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "alias_filler%d.scala", i); + snprintf(body, sizeof(body), "object AliasFiller%d { def value: Int = %d }\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); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/scala_alias_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); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_TRUE(scala_import_alias_edge_is_exact(s, cbm_pipeline_project_name(p))); + + 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(); +} + /* 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; @@ -9437,8 +9648,8 @@ static const cbm_gbuf_node_t *resolve_device_h_from(cbm_gbuf_t *gb) { CBMImport imp = {0}; imp.module_path = "linux/device.h"; imp.local_name = "h"; - return cbm_pipeline_resolve_import_node(&ctx, "arch/x/bugs.c", "p.arch.x.bugs.c.__file__", &imp, - NULL); + return cbm_pipeline_resolve_import_node(&ctx, "arch/x/bugs.c", "p.arch.x.bugs.c.__file__", + CBM_LANG_C, &imp, NULL, NULL); } TEST(pipeline_header_include_target_is_independent_of_registration_order) { @@ -17665,6 +17876,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_python_bare_local_binding_suppresses_weak_edge); RUN_TEST(pipeline_python_bare_local_binding_parallel_suppresses_weak_edge); + RUN_TEST(pipeline_scala_import_alias_resolves_exact_method); + RUN_TEST(pipeline_scala_member_import_in_split_package_binds_owner_member); + RUN_TEST(pipeline_scala_index_built_only_for_scala_importers); + RUN_TEST(pipeline_scala_import_alias_parallel_resolves_exact_method); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments);