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);