diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h
index dc89a6b2d6..360bd22aa5 100644
--- a/internal/cbm/cbm.h
+++ b/internal/cbm/cbm.h
@@ -694,6 +694,24 @@ typedef struct {
int count;
} CBMReturnTypeTable;
+// --- Scala companion cache ---
+// The class/trait/enum names of every scope an object_definition has asked
+// about, keyed by (scope node, name bytes). A scope is scanned once, on its
+// first query; names are byte ranges into the source, never copied. One extra
+// key per scanned scope (len == 0) records the scan itself, so a scope without
+// any class is not rescanned either. Open addressing, power-of-two capacity.
+typedef struct {
+ const void *scope; // ts_node id of the scope (parent) node; NULL = empty slot
+ uint32_t off;
+ uint32_t len;
+} CBMScalaScopeName;
+
+typedef struct {
+ CBMScalaScopeName *slots;
+ uint32_t cap; // 0 until the first query
+ uint32_t count;
+} CBMScalaCompanionCache;
+
typedef struct {
CBMArena *arena;
/* Scratch for AST traversal, owned by the cbm_extract_file_ex call that
@@ -738,6 +756,9 @@ typedef struct {
* POD section index. */
void *doc_memo;
void *doc_pod_index;
+ /* Scala only; lives in `scratch` (or `arena` without one). Nothing in a
+ * CBMFileResult points at it. */
+ CBMScalaCompanionCache scala_companions;
} CBMExtractCtx;
// --- Public API ---
@@ -945,6 +966,11 @@ void cbm_impltrait_push(CBMImplTraitArray *arr, CBMArena *a, CBMImplTrait it);
void cbm_resolvedcall_push(CBMResolvedCallArray *arr, CBMArena *a, CBMResolvedCall rc);
void cbm_channels_push(CBMChannelArray *arr, CBMArena *a, CBMChannel ch);
+/* True for a Scala object_definition with a same-named class/trait/enum
+ * sibling. O(1) per query after the first one in a scope, which scans the
+ * scope's named children once into ctx->scala_companions. */
+bool cbm_scala_is_companion_object(CBMExtractCtx *ctx, TSNode node);
+
// --- Sub-extractor entry points ---
void cbm_extract_definitions(CBMExtractCtx *ctx);
diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c
index e352217998..550c3954b6 100644
--- a/internal/cbm/extract_defs.c
+++ b/internal/cbm/extract_defs.c
@@ -6395,6 +6395,12 @@ static void extract_class_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
} else {
class_qn = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, name, ctx->language);
}
+ /* A Scala class and its companion object are distinct owners despite sharing
+ * the source-level name. Use Scala's conventional singleton-class suffix so
+ * neither container nor their methods overwrite each other in the graph. */
+ if (ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node)) {
+ class_qn = cbm_arena_sprintf(a, "%s$", class_qn);
+ }
const char *label = class_label_for_kind(kind);
// Sway/WGSL: label struct defs as "Struct" and Sway `abi` blocks as
@@ -9250,12 +9256,18 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const char
char *cname = cbm_node_text(ctx->arena, name_node, ctx->source);
if (cname && cname[0]) {
if (saved_enclosing) {
- return cbm_arena_sprintf(ctx->arena, "%s.%s", saved_enclosing, cname);
+ const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", saved_enclosing, cname);
+ return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node)
+ ? cbm_arena_sprintf(ctx->arena, "%s$", qn)
+ : qn;
}
/* Top-level: language-aware module so Java/Go don't double the
* filename stem (matches extract_class_def above). */
- return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, cname,
- ctx->language);
+ const char *qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path,
+ cname, ctx->language);
+ return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node)
+ ? cbm_arena_sprintf(ctx->arena, "%s$", qn)
+ : qn;
}
}
return saved_enclosing;
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/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c
index 086f501a0c..7e14726603 100644
--- a/internal/cbm/extract_unified.c
+++ b/internal/cbm/extract_unified.c
@@ -1156,12 +1156,18 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS
* scope QN matches the def-side class QN (extract_defs.c compute_class_qn /
* extract_class_def), which the lsp_resolve join requires for nested types. */
if (state && state->enclosing_class_qn) {
- return cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name);
+ const char *qn = cbm_arena_sprintf(ctx->arena, "%s.%s", state->enclosing_class_qn, name);
+ return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node)
+ ? cbm_arena_sprintf(ctx->arena, "%s$", qn)
+ : qn;
}
/* Java/Go: directory-based module (see compute_func_qn). */
- return cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name,
- ctx->language);
+ const char *qn =
+ cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language);
+ return ctx->language == CBM_LANG_SCALA && cbm_scala_is_companion_object(ctx, node)
+ ? cbm_arena_sprintf(ctx->arena, "%s$", qn)
+ : qn;
}
/* Forward declaration */
diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c
index 4c4d340f0b..25b8e3eaea 100644
--- a/internal/cbm/helpers.c
+++ b/internal/cbm/helpers.c
@@ -173,6 +173,120 @@ bool cbm_label_is_type_like(const char *label) {
strcmp(label, "Type") == 0 || strcmp(label, "Trait") == 0;
}
+/* --- Scala companion cache (CBMScalaCompanionCache) --- */
+
+static uint64_t scc_hash(const void *scope, const char *bytes, uint32_t len) {
+ uint64_t h = 1469598103934665603ULL;
+ const unsigned char *p = (const unsigned char *)&scope;
+ for (size_t i = 0; i < sizeof(scope); i++) {
+ h = (h ^ p[i]) * 1099511628211ULL;
+ }
+ for (uint32_t i = 0; i < len; i++) {
+ h = (h ^ (unsigned char)bytes[i]) * 1099511628211ULL;
+ }
+ return h;
+}
+
+/* Slot holding (scope, name), or the empty slot where it would go. */
+static CBMScalaScopeName *scc_find(const CBMScalaCompanionCache *c, const char *source,
+ const void *scope, uint32_t off, uint32_t len) {
+ uint32_t mask = c->cap - 1;
+ uint32_t i = (uint32_t)scc_hash(scope, source + off, len) & mask;
+ for (;;) {
+ CBMScalaScopeName *s = &c->slots[i];
+ if (!s->scope) {
+ return s;
+ }
+ if (s->scope == scope && s->len == len &&
+ (len == 0 || memcmp(source + s->off, source + off, len) == 0)) {
+ return s;
+ }
+ i = (i + 1) & mask;
+ }
+}
+
+static bool scc_insert(CBMScalaCompanionCache *c, CBMArena *a, const char *source,
+ const void *scope, uint32_t off, uint32_t len) {
+ if ((c->count + 1) * 2 > c->cap) {
+ uint32_t new_cap = c->cap ? c->cap * 2 : 64;
+ CBMScalaScopeName *old = c->slots;
+ uint32_t old_cap = c->cap;
+ c->slots = cbm_arena_calloc(a, (size_t)new_cap * sizeof(*c->slots));
+ if (!c->slots) {
+ c->slots = old;
+ return false;
+ }
+ c->cap = new_cap;
+ for (uint32_t i = 0; i < old_cap; i++) {
+ if (old[i].scope) {
+ *scc_find(c, source, old[i].scope, old[i].off, old[i].len) = old[i];
+ }
+ }
+ }
+ CBMScalaScopeName *s = scc_find(c, source, scope, off, len);
+ if (!s->scope) {
+ s->scope = scope;
+ s->off = off;
+ s->len = len;
+ c->count++;
+ }
+ return true;
+}
+
+/* First query in a scope: record its class/trait/enum names, plus the
+ * (scope, "") marker that says the scope has been scanned. */
+static bool scc_scan_scope(CBMScalaCompanionCache *c, CBMArena *a, const char *source,
+ TSNode scope) {
+ const void *id = scope.id;
+ if (c->cap && scc_find(c, source, id, 0, 0)->scope) {
+ return true;
+ }
+ if (!scc_insert(c, a, source, id, 0, 0)) {
+ return false;
+ }
+ uint32_t count = ts_node_named_child_count(scope);
+ for (uint32_t i = 0; i < count; i++) {
+ TSNode sibling = ts_node_named_child(scope, i);
+ const char *kind = ts_node_type(sibling);
+ if (strcmp(kind, "class_definition") != 0 && strcmp(kind, "trait_definition") != 0 &&
+ strcmp(kind, "enum_definition") != 0) {
+ continue;
+ }
+ TSNode name = ts_node_child_by_field_name(sibling, TS_FIELD("name"));
+ if (ts_node_is_null(name)) {
+ continue;
+ }
+ uint32_t off = ts_node_start_byte(name);
+ uint32_t len = ts_node_end_byte(name) - off;
+ if (len && !scc_insert(c, a, source, id, off, len)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+bool cbm_scala_is_companion_object(CBMExtractCtx *ctx, TSNode node) {
+ if (!ctx || !ctx->source || strcmp(ts_node_type(node), "object_definition") != 0) {
+ return false;
+ }
+ TSNode name_node = ts_node_child_by_field_name(node, TS_FIELD("name"));
+ TSNode parent = ts_node_parent(node);
+ if (ts_node_is_null(name_node) || ts_node_is_null(parent)) {
+ return false;
+ }
+ uint32_t off = ts_node_start_byte(name_node);
+ uint32_t len = ts_node_end_byte(name_node) - off;
+ if (len == 0 || ts_node_end_byte(name_node) > (uint32_t)ctx->source_len) {
+ return false;
+ }
+ CBMArena *a = ctx->scratch ? ctx->scratch : ctx->arena;
+ CBMScalaCompanionCache *c = &ctx->scala_companions;
+ if (!a || !scc_scan_scope(c, a, ctx->source, parent)) {
+ return false;
+ }
+ return scc_find(c, ctx->source, parent.id, off, len)->scope != NULL;
+}
+
// True when `label` names a data relation: SQL CREATE TABLE / CREATE VIEW, and
// a dbt Model (a Jinja-templated .sql file, which materializes as a warehouse
// table or view). Relations live in the registry so FROM/JOIN and dbt ref()
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..ea4ab16ac2 100644
--- a/src/pipeline/pass_definitions.c
+++ b/src/pipeline/pass_definitions.c
@@ -396,7 +396,8 @@ static void process_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const
* (SQL FROM/JOIN lineage). pass_parallel.c and pipeline_incremental.c seed
* through the same predicate, so the three registries cannot diverge. */
if (node_id > 0 && cbm_label_is_registry_symbol(def->label)) {
- cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label);
+ cbm_registry_add_def(ctx->registry, def->name, def->qualified_name, def->label,
+ def->file_path ? def->file_path : rel);
}
char *file_qn = cbm_pipeline_fqn_compute(ctx->project_name, rel, "__file__");
const cbm_gbuf_node_t *file_node = cbm_gbuf_find_by_qn(ctx->gbuf, file_qn);
@@ -512,7 +513,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 +528,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 +941,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 +956,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 +977,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..9d28be1f45 100644
--- a/src/pipeline/pass_parallel.c
+++ b/src/pipeline/pass_parallel.c
@@ -1691,7 +1691,7 @@ int cbm_parallel_extract(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
/* `file_node_id` is the defining file's node id (0 when it has none), looked up
* once per file by the caller: computing the file QN and finding its node for
* every definition was 700 k allocations and lookups on the Go corpus. */
-static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def,
+static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *def, const char *rel,
int64_t file_node_id, int *reg_entries) {
int edges = 0;
if (!def->name || !def->qualified_name || !def->label) {
@@ -1700,7 +1700,8 @@ static int register_and_link_def(cbm_pipeline_ctx_t *ctx, const CBMDefinition *d
/* Registry membership is defined ONCE by cbm_label_is_registry_symbol
* (helpers.c) — see pass_definitions.c for the per-label rationale. */
if (cbm_label_is_registry_symbol(def->label)) {
- cbm_registry_add(ctx->registry, def->name, def->qualified_name, def->label);
+ cbm_registry_add_def(ctx->registry, def->name, def->qualified_name, def->label,
+ def->file_path ? def->file_path : rel);
(*reg_entries)++;
}
const cbm_gbuf_node_t *def_node = cbm_gbuf_find_by_qn(ctx->gbuf, def->qualified_name);
@@ -1750,7 +1751,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 +1765,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 +1833,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;
}
@@ -1884,12 +1880,13 @@ int cbm_build_registry_from_cache(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t
free(file_qn);
pp_add_file_doc(file_node, result->module_doc);
for (int d = 0; d < result->defs.count; d++) {
- defines_edges +=
- register_and_link_def(ctx, &result->defs.items[d], file_node_id, ®_entries);
+ defines_edges += register_and_link_def(ctx, &result->defs.items[d], rel,
+ file_node_id, ®_entries);
}
}
- 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 +1894,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..9ec34ddf2a 100644
--- a/src/pipeline/pass_pkgmap.c
+++ b/src/pipeline/pass_pkgmap.c
@@ -2134,11 +2134,163 @@ 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. Only object
+ * members are importable, so when Owner has a companion the `Owner$`
+ * scope is tried before the class. */
+ static const char *const scopes[] = {"$", ""};
+ for (size_t si = 0; si < sizeof(scopes) / sizeof(scopes[0]); si++) {
+ char member_qn[CBM_SZ_512];
+ if (snprintf(member_qn, sizeof(member_qn), "%s%s.%s", owner->qualified_name, scopes[si],
+ 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 +2378,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 +2435,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 +2628,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 +2679,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 +2720,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.h b/src/pipeline/pipeline.h
index c1d571ecdc..80379e1934 100644
--- a/src/pipeline/pipeline.h
+++ b/src/pipeline/pipeline.h
@@ -239,6 +239,14 @@ void cbm_registry_free(cbm_registry_t *r);
void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified_name,
const char *label);
+/* cbm_registry_add for a definition with a known declaring file. The one
+ * entry point the pipeline registers through: besides the plain add, a
+ * type-like `Foo$` declared in a Scala file is recorded as a companion owner,
+ * which is the only thing that lets the resolver read `Foo.m` as `Foo$.m`.
+ * `Foo$` from any other language is an ordinary name. */
+void cbm_registry_add_def(cbm_registry_t *r, const char *name, const char *qualified_name,
+ const char *label, const char *file_path);
+
/* Resolve a callee name using prioritized strategies.
* import_map: NULL-terminated array of {local_name, resolved_qn} pairs, or NULL.
* Returns result with qualified_name="" if unresolved.
diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c
index 99195cc2c0..2f5bcdafa0 100644
--- a/src/pipeline/pipeline_incremental.c
+++ b/src/pipeline/pipeline_incremental.c
@@ -1060,7 +1060,7 @@ static void registry_visitor(const cbm_gbuf_node_t *node, void *userdata) {
if (!incr_label_is_registry_symbol(node->label)) {
return;
}
- cbm_registry_add(r, node->name, node->qualified_name, node->label);
+ cbm_registry_add_def(r, node->name, node->qualified_name, node->label, node->file_path);
}
static void free_incremental_result_cache(CBMFileResult **cache, int count) {
@@ -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/src/pipeline/registry.c b/src/pipeline/registry.c
index ff56a41ac4..0186da7924 100644
--- a/src/pipeline/registry.c
+++ b/src/pipeline/registry.c
@@ -26,6 +26,7 @@ enum { REG_MAX_CANDIDATES = 256 };
#define DEFAULT_CONFIDENCE 0.5
#include "pipeline/pipeline.h"
#include "cbm.h" /* cbm_label_is_relation — the resolve-time relation veto */
+#include "discover/discover.h" /* cbm_language_for_filename — Scala companion gate */
#include "foundation/compat.h" /* CBM_TLS */
#include "foundation/hash_table.h"
#include "foundation/dyn_array.h"
@@ -97,6 +98,12 @@ struct cbm_registry {
/* byName: simpleName → qn_array_t* (heap-owned) */
CBMHashTable *by_name;
+
+ /* Scala companion owners (`pkg.Foo$`), recorded by cbm_registry_add_def
+ * for type-like definitions in Scala files only. Keys borrow `exact`'s
+ * owned QN. NULL until the first companion, so every other language's
+ * registry never allocates it and every `$` probe below is one NULL test. */
+ CBMHashTable *companions;
};
/* ── Helpers ─────────────────────────────────────────────────────── */
@@ -865,6 +872,7 @@ void cbm_registry_free(cbm_registry_t *r) {
/* by_name first: its items borrow exact's keys. */
cbm_ht_foreach(r->by_name, free_qn_array, NULL);
cbm_ht_free(r->by_name);
+ cbm_ht_free(r->companions); /* keys borrow exact's */
cbm_ht_foreach(r->exact, free_label, NULL);
cbm_ht_free(r->exact);
for (int i = 0; i < r->label_pool_n; i++) {
@@ -982,6 +990,54 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified
}
}
+static bool path_is_scala(const char *path) {
+ const char *base = path;
+ for (const char *p = path; *p; p++) {
+ if (*p == '/' || *p == '\\') {
+ base = p + SKIP_ONE;
+ }
+ }
+ return cbm_language_for_filename(base) == CBM_LANG_SCALA;
+}
+
+void cbm_registry_add_def(cbm_registry_t *r, const char *name, const char *qualified_name,
+ const char *label, const char *file_path) {
+ cbm_registry_add(r, name, qualified_name, label);
+ if (!r || !qualified_name || !label || !file_path) {
+ return;
+ }
+ size_t len = strlen(qualified_name);
+ if (len < 2 || qualified_name[len - 1] != '$' || !cbm_label_is_type_like(label) ||
+ !path_is_scala(file_path)) {
+ return;
+ }
+ const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name);
+ if (!owned_qn) {
+ return;
+ }
+ if (!r->companions) {
+ r->companions = cbm_ht_create(CBM_SZ_256);
+ if (!r->companions) {
+ return;
+ }
+ }
+ cbm_ht_set(r->companions, owned_qn, (void *)owned_qn);
+}
+
+/* True when the first `owner_len` bytes of `owner` name a class whose Scala
+ * companion (`$`) is registered. The only gate on every `$` probe in
+ * the resolver: a Java or JS `Foo$` next to `Foo` is never a companion. */
+static bool companion_known(const cbm_registry_t *r, const char *owner, size_t owner_len) {
+ if (!r->companions || owner_len == 0 || owner_len + 2 > CBM_SZ_512) {
+ return false;
+ }
+ char key[CBM_SZ_512];
+ memcpy(key, owner, owner_len);
+ key[owner_len] = '$';
+ key[owner_len + 1] = '\0';
+ return cbm_ht_has(r->companions, key);
+}
+
/* ── Lookup ──────────────────────────────────────────────────────── */
bool cbm_registry_exists(const cbm_registry_t *r, const char *qn) {
@@ -1079,26 +1135,46 @@ static cbm_resolution_t resolve_import_map(const cbm_registry_t *r, const char *
if (stored_key) {
return (cbm_resolution_t){stored_key, "import_map", CONF_IMPORT_MAP, REG_RESOLVED};
}
-
- /* import_map_suffix fallback: find a QN starting with resolved+"." and
- * ending with "."+suffix. Any such QN's last segment equals the last
- * segment of suffix, so probe the by_name index and tail-check the (few)
- * candidates instead of cbm_ht_foreach over the WHOLE exact table — that
- * scan ran per unresolved call and dominated elasticsearch's resolve
- * phase (94% of samples: 700k-entry foreach + strlen per entry). */
if (suffix && suffix[0]) {
+ /* Scala: an import binds the class, but `Foo.method()` on a class name
+ * is a call into the companion object, whose QN carries a trailing `$`.
+ * Probed only when `$` is a registered Scala companion, so a
+ * Java or JS class that happens to be named `Foo$` never takes part. */
+ bool companion = companion_known(r, resolved, strlen(resolved));
+ if (companion) {
+ snprintf(candidate, sizeof(candidate), "%s$.%s", resolved, suffix);
+ stored_key = cbm_ht_get_key(r->exact, candidate);
+ if (stored_key) {
+ return (cbm_resolution_t){stored_key, "import_map", CONF_IMPORT_MAP, REG_RESOLVED};
+ }
+ }
+
+ /* import_map_suffix fallback: find a QN starting with resolved+"." and
+ * ending with "."+suffix. Any such QN's last segment equals the last
+ * segment of suffix, so probe the by_name index and tail-check the
+ * (few) candidates instead of cbm_ht_foreach over the WHOLE exact
+ * table — that scan ran per unresolved call and dominated
+ * elasticsearch's resolve phase (94% of samples: 700k-entry foreach +
+ * strlen per entry). */
char resolved_dot[CBM_SZ_512];
+ char companion_dot[CBM_SZ_512];
char dot_suffix[CBM_SZ_256];
snprintf(resolved_dot, sizeof(resolved_dot), "%s.", resolved);
+ snprintf(companion_dot, sizeof(companion_dot), "%s$.", resolved);
snprintf(dot_suffix, sizeof(dot_suffix), ".%s", suffix);
qn_array_t *arr = cbm_ht_get(r->by_name, simple_name(suffix));
if (arr) {
size_t rd_len = strlen(resolved_dot);
+ size_t cd_len = strlen(companion_dot);
size_t ds_len = strlen(dot_suffix);
for (int i = 0; i < arr->count; i++) {
const char *qn = arr->items[i];
size_t klen = strlen(qn);
- if (klen >= rd_len + ds_len && strncmp(qn, resolved_dot, rd_len) == 0 &&
+ bool under_owner =
+ klen >= rd_len + ds_len && strncmp(qn, resolved_dot, rd_len) == 0;
+ bool under_companion =
+ companion && klen >= cd_len + ds_len && strncmp(qn, companion_dot, cd_len) == 0;
+ if ((under_owner || under_companion) &&
strcmp(qn + klen - ds_len, dot_suffix) == 0) {
return (cbm_resolution_t){qn, "import_map_suffix", CONF_IMPORT_MAP_SUFFIX,
REG_RESOLVED};
@@ -1118,6 +1194,23 @@ static cbm_resolution_t resolve_same_module(const cbm_registry_t *r, const char
if (stored_key) {
return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED};
}
+ /* Scala `Owner.member` in the declaring file: the member may live under
+ * the companion `Owner$`. Each owner segment is tried once, and only when
+ * `.$` is a registered Scala companion. */
+ for (const char *dot = r->companions ? strchr(callee_name, '.') : NULL; dot;
+ dot = strchr(dot + 1, '.')) {
+ int owner_len = snprintf(candidate, sizeof(candidate), "%s.%.*s", module_qn,
+ (int)(dot - callee_name), callee_name);
+ if (owner_len < 0 || owner_len >= (int)sizeof(candidate) ||
+ !companion_known(r, candidate, (size_t)owner_len)) {
+ continue;
+ }
+ snprintf(candidate + owner_len, sizeof(candidate) - (size_t)owner_len, "$%s", dot);
+ stored_key = cbm_ht_get_key(r->exact, candidate);
+ if (stored_key) {
+ return (cbm_resolution_t){stored_key, "same_module", CONF_SAME_MODULE, REG_RESOLVED};
+ }
+ }
if (suffix && suffix[0]) {
snprintf(candidate, sizeof(candidate), "%s.%s", module_qn, suffix);
stored_key = cbm_ht_get_key(r->exact, candidate);
@@ -1171,6 +1264,49 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const
* as trustworthy as a same-module hit. */
#define CONF_QUALIFIED_SUFFIX 0.90
+/* Segment equality that lets a Scala companion object stand in for its class:
+ * a call written `Foo.make` reaches the method declared under `Foo$`. `cand`
+ * points into `cand_qn`; the `$` form is accepted only when the candidate QN
+ * up to and including that segment is a registered Scala companion, so a
+ * Java or JS segment `Foo$` is plain text that does not equal `Foo`. */
+static bool qn_seg_matches(const cbm_registry_t *r, const char *cand_qn, const char *seg,
+ size_t seg_len, const char *cand, size_t cand_len) {
+ if (cand_len == seg_len) {
+ return strncmp(seg, cand, seg_len) == 0;
+ }
+ return cand_len == seg_len + 1 && cand[seg_len] == '$' && strncmp(seg, cand, seg_len) == 0 &&
+ companion_known(r, cand_qn, (size_t)(cand - cand_qn) + seg_len);
+}
+
+/* True when `qn` equals `dotted` or ends with ".", compared segment by
+ * segment from the right so companion segments (`Foo$`) match `Foo`. */
+static bool qn_tail_matches(const cbm_registry_t *r, const char *qn, const char *dotted) {
+ const char *q_end = qn + strlen(qn);
+ const char *d_end = dotted + strlen(dotted);
+ for (;;) {
+ const char *q_dot = q_end;
+ while (q_dot > qn && q_dot[-1] != '.') {
+ q_dot--;
+ }
+ const char *d_dot = d_end;
+ while (d_dot > dotted && d_dot[-1] != '.') {
+ d_dot--;
+ }
+ if (!qn_seg_matches(r, qn, d_dot, (size_t)(d_end - d_dot), q_dot,
+ (size_t)(q_end - q_dot))) {
+ return false;
+ }
+ if (d_dot == dotted) {
+ return true; /* every callee segment matched at a segment boundary */
+ }
+ if (q_dot == qn) {
+ return false; /* callee has more segments than the candidate */
+ }
+ q_end = q_dot - 1;
+ d_end = d_dot - 1;
+ }
+}
+
/* When a callee is package/namespace-qualified (Foo::Bar::sub or Foo.Bar.sub),
* disambiguate among same-simple-name candidates by matching the FULL qualified
* tail against each candidate QN at a segment boundary. Returns the sole
@@ -1182,7 +1318,8 @@ static cbm_resolution_t resolve_multi_with_imports(const qn_array_t *arr, const
* both reduce to "run", so the bare-name scorer would route every caller to a
* single winner. Language agnostic: callees with no separator return NULL and
* leave behavior unchanged. */
-static const char *qualified_suffix_match(const qn_array_t *arr, const char *callee_name) {
+static const char *qualified_suffix_match(const cbm_registry_t *r, const qn_array_t *arr,
+ const char *callee_name) {
/* Normalize "::" → "." so the tail composes with dotted candidate QNs. */
char dotted[CBM_SZ_512];
size_t w = 0;
@@ -1203,16 +1340,7 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal
const char *match = NULL;
for (int i = 0; i < arr->count; i++) {
const char *qn = arr->items[i];
- size_t qlen = strlen(qn);
- if (qlen < w) {
- continue;
- }
- const char *tail = qn + (qlen - w);
- if (strcmp(tail, dotted) != 0) {
- continue;
- }
- /* Segment boundary: tail is the whole QN or is preceded by '.'. */
- if (tail != qn && tail[-1] != '.') {
+ if (!qn_tail_matches(r, qn, dotted)) {
continue;
}
if (match) {
@@ -1238,7 +1366,8 @@ static const char *qualified_suffix_match(const qn_array_t *arr, const char *cal
*
* Language agnostic by design: the registry holds no language, and every
* language that writes receiver chains gains the same protection. */
-static bool receiver_chain_admits(const char *callee_name, const char *candidate_qn) {
+static bool receiver_chain_admits(const cbm_registry_t *r, const char *callee_name,
+ const char *candidate_qn) {
/* Normalize "::" -> "." so the chain composes with dotted candidate QNs,
* the same way qualified_suffix_match does. */
char dotted[CBM_SZ_512];
@@ -1304,7 +1433,7 @@ static bool receiver_chain_admits(const char *callee_name, const char *candidate
for (const char *anc = candidate_qn; anc < cand_last;) {
const char *anc_end = strchr(anc, '.');
size_t anc_len = (size_t)(anc_end - anc);
- if (anc_len == len && len > 0 && strncmp(seg, anc, len) == 0) {
+ if (len > 0 && qn_seg_matches(r, candidate_qn, seg, len, anc, anc_len)) {
return true;
}
anc = anc_end + SKIP_ONE;
@@ -1331,7 +1460,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char
* candidates by full qualified tail, before bare-name scoring collapses
* them onto a single winner. */
if (arr->count > 1) {
- const char *q = qualified_suffix_match(arr, callee_name);
+ const char *q = qualified_suffix_match(r, arr, callee_name);
if (q) {
return (cbm_resolution_t){q, "qualified_suffix", CONF_QUALIFIED_SUFFIX, REG_RESOLVED};
}
@@ -1339,7 +1468,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char
/* Strategy 3: unique name */
if (arr->count == SKIP_ONE) {
- if (!receiver_chain_admits(callee_name, arr->items[0])) {
+ if (!receiver_chain_admits(r, callee_name, arr->items[0])) {
return empty_result();
}
double conf = CONF_UNIQUE_NAME;
@@ -1357,7 +1486,7 @@ static cbm_resolution_t resolve_name_lookup(const cbm_registry_t *r, const char
const char *best = best_by_import_distance((const char **)arr->items, qn_test_flags(arr),
arr->count, module_qn);
if (best) {
- if (!receiver_chain_admits(callee_name, best)) {
+ if (!receiver_chain_admits(r, callee_name, best)) {
return empty_result();
}
double conf = candidate_count_penalty(CONF_SUFFIX_MATCH, arr->count);
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..fe155e5662 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,182 @@ TEST(extract_ts_member_call_flags_is_method) {
PASS();
}
+TEST(extract_scala_companion_owners_are_distinct) {
+ CBMFileResult *r = extract("case class Rational private (n: Int, d: Int) {\n"
+ " lazy val isWhole: Boolean = d == 1\n"
+ " def reciprocal: Rational = Rational(d, n)\n"
+ "}\n"
+ "case object Rational {\n"
+ " val zero: Rational = Rational(0, 1)\n"
+ " def apply(n: Int, d: Int): Rational = new Rational(n, d)\n"
+ "}\n",
+ CBM_LANG_SCALA, "t", "Rational.scala");
+ ASSERT_NOT_NULL(r);
+ ASSERT_FALSE(r->has_error);
+
+ ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational"));
+ ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational.reciprocal"));
+ ASSERT_TRUE(has_def_qn(r, "t.Rational.Rational$.apply"));
+ /* Class-body vals keep their module-level QN like every other language;
+ * parent_class tells the two owners apart. */
+ ASSERT_TRUE(has_def_qn(r, "t.Rational.isWhole"));
+ ASSERT_TRUE(has_def_qn(r, "t.Rational.zero"));
+
+ cbm_free_result(r);
+ PASS();
+}
+
+TEST(extract_scala_trait_companion_and_standalone_object) {
+ CBMFileResult *r = extract("trait Codec {\n"
+ " def encode(s: String): Array[Byte]\n"
+ "}\n"
+ "object Codec {\n"
+ " def utf8: Codec = ???\n"
+ "}\n"
+ "object Registry {\n"
+ " def lookup(name: String): Option[Codec] = None\n"
+ "}\n",
+ CBM_LANG_SCALA, "t", "Codec.scala");
+ ASSERT_NOT_NULL(r);
+ ASSERT_FALSE(r->has_error);
+
+ /* Trait companions collide just like class companions. */
+ ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec"));
+ ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec.encode"));
+ ASSERT_TRUE(has_def_qn(r, "t.Codec.Codec$.utf8"));
+ /* A standalone object has nothing to collide with and keeps its plain QN. */
+ ASSERT_TRUE(has_def_qn(r, "t.Codec.Registry"));
+ ASSERT_FALSE(has_def_qn(r, "t.Codec.Registry$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Codec.Registry.lookup"));
+
+ cbm_free_result(r);
+ PASS();
+}
+
+/* Companion detection is answered per scope from a cache built on the first
+ * query in that scope, so the same name must be judged against its own
+ * scope's siblings only: `object Node` is a companion at the top level and
+ * standalone inside `Tree`, and a scope with no class at all (`Ops`) must not
+ * turn any of its objects into companions. A flat sealed hierarchy of many
+ * case classes and case objects in one scope is the shape that used to be
+ * quadratic; every object there must still be classified correctly. */
+TEST(extract_scala_companion_detection_is_scoped) {
+ CBMFileResult *r = extract("class Node(v: Int)\n"
+ "object Node { def leaf: Node = new Node(0) }\n"
+ "object Tree {\n"
+ " object Node { def root: Int = 0 }\n"
+ " class Leaf\n"
+ " object Leaf { def make: Leaf = new Leaf }\n"
+ "}\n"
+ "object Ops {\n"
+ " object Node { def count: Int = 1 }\n"
+ " object Leaf { def count: Int = 2 }\n"
+ "}\n"
+ "sealed trait Op\n"
+ "case class Add(n: Int) extends Op\n"
+ "case object Add extends Op\n"
+ "case class Sub(n: Int) extends Op\n"
+ "case object Neg extends Op\n"
+ "case object Op { def all: Seq[Op] = Nil }\n",
+ CBM_LANG_SCALA, "t", "Ast.scala");
+ ASSERT_NOT_NULL(r);
+ ASSERT_FALSE(r->has_error);
+
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Node"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Node$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Node$.leaf"));
+ /* Inside `Tree` there is no class Node, so its `object Node` is standalone,
+ * while `Leaf` has a class sibling there. */
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Node"));
+ ASSERT_FALSE(has_def_qn(r, "t.Ast.Tree.Node$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Node.root"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Leaf"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Leaf$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Tree.Leaf$.make"));
+ /* `Ops` holds objects only; nothing there is a companion, whatever the
+ * neighbouring scopes declare. */
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Ops.Node"));
+ ASSERT_FALSE(has_def_qn(r, "t.Ast.Ops.Node$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Ops.Leaf"));
+ ASSERT_FALSE(has_def_qn(r, "t.Ast.Ops.Leaf$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Ops.Node.count"));
+ /* Sealed hierarchy in one scope: companions of a case class and of the
+ * sealed trait get `$`, a case object without a class sibling does not. */
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Add"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Add$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Sub"));
+ ASSERT_FALSE(has_def_qn(r, "t.Ast.Sub$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Neg"));
+ ASSERT_FALSE(has_def_qn(r, "t.Ast.Neg$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Op"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Op$"));
+ ASSERT_TRUE(has_def_qn(r, "t.Ast.Op$.all"));
+
+ cbm_free_result(r);
+ 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 +9426,12 @@ 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_companion_owners_are_distinct);
+ RUN_TEST(extract_scala_trait_companion_and_standalone_object);
+ RUN_TEST(extract_scala_companion_detection_is_scoped);
+ 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..d8606efe3d 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,274 @@ 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();
+}
+
+static void write_scala_companion_fixture(const char *tmp) {
+ write_temp_file(
+ tmp, "rational.scala",
+ "package numeric\n"
+ "class Rational(n: Int, d: Int) { def reciprocal: Rational = Rational.make(d, n) }\n"
+ "object Rational {\n"
+ " def make(n: Int, d: Int): Rational = new Rational(n, d)\n"
+ " def fromInt(n: Int): Rational = make(n, 1)\n"
+ "}\n");
+ write_temp_file(tmp, "factory_user.scala",
+ "package app\n"
+ "import numeric.Rational\n"
+ "object FactoryUser { def half: Rational = Rational.make(1, 2) }\n");
+ write_temp_file(tmp, "member_user.scala",
+ "package app\n"
+ "import numeric.Rational.fromInt\n"
+ "object MemberUser { def two: Rational = fromInt(2) }\n");
+}
+
+static bool scala_companion_edges_are_exact(cbm_store_t *s, const char *project) {
+ return cross_file_call_to_owner_has_strategy(s, project, "half", "make", "Rational$.make",
+ "import_map") &&
+ cross_file_call_to_owner_has_strategy(s, project, "two", "fromInt", "Rational$.fromInt",
+ "import_map");
+}
+
+/* A class with a companion object is one importable name. `import pkg.Foo`
+ * must not fail closed as ambiguous now that class and companion have
+ * distinct QNs, `Foo.factory()` must bind into the companion (`Foo$`), and
+ * `import pkg.Foo.member` must bind the companion member. */
+TEST(pipeline_scala_companion_import_and_factory_call) {
+ char tmp[256];
+ snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_companion_XXXXXX");
+ if (!cbm_mkdtemp(tmp)) {
+ FAIL("tmpdir");
+ }
+ write_scala_companion_fixture(tmp);
+
+ char db_path[512];
+ snprintf(db_path, sizeof(db_path), "%s/scala_companion.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_companion_edges_are_exact(s, cbm_pipeline_project_name(p)));
+
+ cbm_store_close(s);
+ cbm_pipeline_free(p);
+ th_rmtree(tmp);
+ PASS();
+}
+
+/* The parallel route registers definitions through its own serial registry
+ * build; the companion owner must be recorded there too, or `Rational.make`
+ * falls back to the class on a 4-worker index. */
+TEST(pipeline_scala_companion_parallel_import_and_factory_call) {
+ char tmp[256];
+ snprintf(tmp, sizeof(tmp), "/tmp/cbm_scala_companion_par_XXXXXX");
+ if (!cbm_mkdtemp(tmp)) {
+ FAIL("tmpdir");
+ }
+ write_scala_companion_fixture(tmp);
+ for (int i = 0; i < 52; i++) {
+ char name[64];
+ char body[128];
+ snprintf(name, sizeof(name), "companion_filler%d.scala", i);
+ snprintf(body, sizeof(body), "object CompanionFiller%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_companion_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_companion_edges_are_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();
+}
+
+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 +9743,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 +17971,12 @@ 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_companion_import_and_factory_call);
+ RUN_TEST(pipeline_scala_companion_parallel_import_and_factory_call);
+ 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);
diff --git a/tests/test_registry.c b/tests/test_registry.c
index 278e97d9b0..590f8023de 100644
--- a/tests/test_registry.c
+++ b/tests/test_registry.c
@@ -1288,6 +1288,113 @@ TEST(resolve_import_map_alias_with_suffix_hits_method) {
PASS();
}
+/* Scala companion objects carry a `$` suffix on their QN. A call written on
+ * the class name (`Rational.make`) must reach the companion member through
+ * every strategy that compares owner segments: import_map, same_module and
+ * the qualified-tail disambiguator. Non-`$` QNs are matched exactly as before. */
+TEST(resolve_scala_companion_owner_matches_class_name) {
+ cbm_registry_t *r = cbm_registry_new();
+ const char *f = "num/Rational.scala";
+ cbm_registry_add_def(r, "Rational", "proj.num.Rational.Rational", "Class", f);
+ cbm_registry_add_def(r, "Rational", "proj.num.Rational.Rational$", "Class", f);
+ cbm_registry_add_def(r, "make", "proj.num.Rational.Rational$.make", "Method", f);
+ cbm_registry_add_def(r, "make", "proj.other.Builder.Builder.make", "Method",
+ "other/Builder.scala");
+
+ /* import_map: the import binds the class; the method lives in Rational$ */
+ const char *keys[] = {"Rational"};
+ const char *vals[] = {"proj.num.Rational.Rational"};
+ cbm_resolution_t res = cbm_registry_resolve(r, "Rational.make", "proj.app.Main", keys, vals, 1);
+ ASSERT_STR_EQ(res.qualified_name, "proj.num.Rational.Rational$.make");
+ ASSERT_STR_EQ(res.strategy, "import_map");
+
+ /* same_module: called from the declaring file */
+ res = cbm_registry_resolve(r, "Rational.make", "proj.num.Rational", NULL, NULL, 0);
+ ASSERT_STR_EQ(res.qualified_name, "proj.num.Rational.Rational$.make");
+ ASSERT_STR_EQ(res.strategy, "same_module");
+
+ /* qualified_suffix: two `make` candidates, the owner segment picks Rational$ */
+ res = cbm_registry_resolve(r, "Rational.make", "proj.elsewhere.X", NULL, NULL, 0);
+ ASSERT_STR_EQ(res.qualified_name, "proj.num.Rational.Rational$.make");
+ ASSERT_STR_EQ(res.strategy, "qualified_suffix");
+
+ /* A plain owner does not match a `$`-less candidate any more loosely. */
+ res = cbm_registry_resolve(r, "Builder.make", "proj.elsewhere.X", NULL, NULL, 0);
+ ASSERT_STR_EQ(res.qualified_name, "proj.other.Builder.Builder.make");
+ ASSERT_STR_EQ(res.strategy, "qualified_suffix");
+ res = cbm_registry_resolve(r, "Other.make", "proj.elsewhere.X", NULL, NULL, 0);
+ ASSERT(res.strategy == NULL || strcmp(res.strategy, "qualified_suffix") != 0);
+ cbm_registry_free(r);
+ PASS();
+}
+
+/* The companion rules are keyed on the registry's own record of Scala
+ * companions, not on the `$` character: the plain cbm_registry_add records
+ * nothing, and neither does cbm_registry_add_def for a non-Scala file. */
+TEST(resolve_dollar_owner_is_companion_only_when_registered_from_scala) {
+ cbm_registry_t *r = cbm_registry_new();
+ cbm_registry_add(r, "Rational", "proj.num.Rational.Rational", "Class");
+ cbm_registry_add(r, "Rational", "proj.num.Rational.Rational$", "Class");
+ cbm_registry_add(r, "make", "proj.num.Rational.Rational$.make", "Method");
+ cbm_registry_add(r, "make", "proj.other.Builder.Builder.make", "Method");
+ const char *keys[] = {"Rational"};
+ const char *vals[] = {"proj.num.Rational.Rational"};
+ cbm_resolution_t res = cbm_registry_resolve(r, "Rational.make", "proj.app.Main", keys, vals, 1);
+ ASSERT(res.strategy == NULL || strcmp(res.strategy, "import_map") != 0);
+ res = cbm_registry_resolve(r, "Rational.make", "proj.num.Rational", NULL, NULL, 0);
+ ASSERT(res.strategy == NULL || strcmp(res.strategy, "same_module") != 0);
+ res = cbm_registry_resolve(r, "Rational.make", "proj.elsewhere.X", NULL, NULL, 0);
+ ASSERT(res.strategy == NULL || strcmp(res.strategy, "qualified_suffix") != 0);
+ cbm_registry_free(r);
+ PASS();
+}
+
+/* `$` is a legal identifier character in Java and JS/TS (`user$` streams,
+ * `Foo$` helper classes), so a class `Foo$` next to `Foo` is an ordinary
+ * sibling there. With both registered from a Java or a TypeScript file,
+ * `Foo.m` resolves exactly as it does on a registry without any companion
+ * rule (the expectations below were taken from that registry): import_map,
+ * same_module and the qualified tail never reach the `$` owner's member, and
+ * the receiver chain does not admit it as a unique-name hit. */
+TEST(resolve_java_ts_dollar_class_next_to_class_is_unchanged) {
+ const char *files[] = {"a/Foo.java", "a/Foo.ts"};
+ for (size_t fi = 0; fi < sizeof(files) / sizeof(files[0]); fi++) {
+ const char *f = files[fi];
+ cbm_registry_t *r = cbm_registry_new();
+ cbm_registry_add_def(r, "Foo", "proj.a.Foo", "Class", f);
+ cbm_registry_add_def(r, "Foo$", "proj.a.Foo$", "Class", f);
+ cbm_registry_add_def(r, "m", "proj.a.Foo$.m", "Method", f);
+ cbm_registry_add_def(r, "m", "proj.b.Bar.m", "Method", "b/Bar.java");
+
+ const char *keys[] = {"Foo"};
+ const char *vals[] = {"proj.a.Foo"};
+ /* Without companion rules the import path gives up and the bare-name
+ * scorer picks the import-nearest candidate at suffix_match confidence;
+ * that verdict must stay exactly as it is. */
+ cbm_resolution_t res = cbm_registry_resolve(r, "Foo.m", "proj.app.Main", keys, vals, 1);
+ ASSERT_STR_EQ(res.strategy, "suffix_match");
+ ASSERT_STR_EQ(res.qualified_name, "proj.a.Foo$.m");
+
+ res = cbm_registry_resolve(r, "Foo.m", "proj.a", NULL, NULL, 0);
+ ASSERT_NULL(res.strategy);
+
+ res = cbm_registry_resolve(r, "Foo.m", "proj.elsewhere.X", NULL, NULL, 0);
+ ASSERT_NULL(res.strategy);
+ cbm_registry_free(r);
+
+ /* Unique candidate: the receiver chain `Foo` must not be read as the
+ * ancestor `Foo$`. */
+ r = cbm_registry_new();
+ cbm_registry_add_def(r, "Foo", "proj.a.Foo", "Class", f);
+ cbm_registry_add_def(r, "Foo$", "proj.a.Foo$", "Class", f);
+ cbm_registry_add_def(r, "m", "proj.a.Foo$.m", "Method", f);
+ res = cbm_registry_resolve(r, "Foo.m", "proj.elsewhere.X", NULL, NULL, 0);
+ ASSERT_NULL(res.strategy);
+ cbm_registry_free(r);
+ }
+ PASS();
+}
+
SUITE(registry) {
/* FQN */
RUN_TEST(fqn_simple);
@@ -1325,6 +1432,9 @@ SUITE(registry) {
RUN_TEST(resolve_import_map_bare_alias);
RUN_TEST(resolve_import_map_aliased_from_import);
RUN_TEST(resolve_import_map_alias_with_suffix_hits_method);
+ RUN_TEST(resolve_scala_companion_owner_matches_class_name);
+ RUN_TEST(resolve_dollar_owner_is_companion_only_when_registered_from_scala);
+ RUN_TEST(resolve_java_ts_dollar_class_next_to_class_is_unchanged);
RUN_TEST(resolve_unique_name);
RUN_TEST(resolve_unresolved);
RUN_TEST(resolve_many_nodes);