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