diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py
index 2cace7b..89f2b5d 100644
--- a/cldk/analysis/java/backend.py
+++ b/cldk/analysis/java/backend.py
@@ -512,12 +512,27 @@ class _Addressing(NamedTuple):
candidates: List[CallableCandidate]
-def duplicate_type_name(qualified_name: str) -> str:
- """The defect message for two declarations that spell one qualified name — which would make a
+def duplicate_type_name(qualified_name: str, paths: List[str]) -> str:
+ """The message for two declarations that spell one qualified name — which makes a
``get_call_graph()`` node key and a ``get_class()`` key ambiguous, so it is surfaced rather than
letting the second silently shadow the first. Both backends raise this text, identically, and
- it names only the qualified name: a ``can://`` id must not appear in a message (E6)."""
- return f"type qualified name {qualified_name!r} is declared twice: codeanalyzer-java emitted two declarations that spell one name"
+ it names the qualified name and the files that declare it: a ``can://`` id must not appear in a
+ message (E6).
+
+
It names the files because the reader cannot otherwise act on it. It no longer attributes the
+ duplication to the analyzer: two compilation units genuinely declaring one name is a property of
+ the source tree — the realistic case being services that vendor one shared library — and the
+ analyzer emitted both correctly, with distinct ids.
+
+
Raised at the ambiguous *query*, never while indexing. Refusing at index time made an entire
+ application unloadable over a handful of contested names, when every other name in it answers
+ perfectly well (#420)."""
+ where = ", ".join(sorted(paths))
+ return (
+ f"type qualified name {qualified_name!r} is declared in {len(paths)} files ({where}): "
+ "several declarations spell one name, so a query naming it cannot be answered — "
+ "analyse the copies separately, or deduplicate them in source"
+ )
def unhomed_endpoint(node_id: str) -> str:
@@ -548,6 +563,17 @@ class JavaAnalysisBackend(AnalysisBackend[JApplication, JCompilationUnit, JType,
P: ClassVar[str] = "J"
N: ClassVar[str] = "J"
+ def _refuse_if_contested(self, qualified_class_name: str) -> None:
+ """Refuse a name-addressed query whose name several declarations spell.
+
+ The index keeps every copy reachable by its own ``can://`` id, so nothing is lost and the
+ application always loads; what cannot be answered is a question phrased as a *name*, because
+ the name genuinely denotes more than one declaration. Both backends route their
+ name-addressed lookups through here, so they fail at the same point on the same input."""
+ paths = getattr(self, "_contested", {}).get(qualified_class_name)
+ if paths:
+ raise CodeanalyzerExecutionException(duplicate_type_name(qualified_class_name, paths))
+
# =====================================================================================
# The addressing surface (leg 3b, Task 1): locate / resolve / source / describe.
#
diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py
index c31e78e..0d1a485 100644
--- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py
+++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py
@@ -340,6 +340,10 @@ def _index(self) -> None:
self._types: Dict[str, JType] = {}
self._file_of: Dict[str, str] = {}
self._callables: Dict[str, Tuple[JType, JCallable]] = {}
+ #: qualified name → every file declaring it, for the names more than one file declares.
+ #: Populated while flattening and consulted by name-addressed queries; see
+ #: :meth:`JavaAnalysisBackend._refuse_if_contested`.
+ self._contested: Dict[str, List[str]] = {}
for path, unit in self.application.symbol_table.items():
for t in unit.types.values():
self._add_type(t, path)
@@ -347,9 +351,13 @@ def _index(self) -> None:
def _add_type(self, t: JType, path: str) -> None:
name = t.qualified_name
if name in self._types:
- raise CodeanalyzerExecutionException(duplicate_type_name(name))
- self._types[name] = t
- self._file_of[name] = path
+ # Recorded, not raised: the copies stay reachable by their own ids, and only a query
+ # phrased as this name is unanswerable. Refusing here would lose the whole application
+ # over one contested name (#420).
+ self._contested.setdefault(name, [self._file_of[name]]).append(path)
+ else:
+ self._types[name] = t
+ self._file_of[name] = path
for c in t.callables.values():
self._callables[c.id] = (t, c)
for lt in c.types.values():
@@ -823,6 +831,7 @@ def get_all_classes(self) -> Dict[str, JType]:
return dict(self._types)
def get_class(self, qualified_class_name: str) -> JType | None:
+ self._refuse_if_contested(qualified_class_name)
return self._types.get(qualified_class_name)
def get_all_methods_in_application(self) -> Dict[str, Dict[str, JCallable]]:
diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py
index 103b2cc..faa0bc7 100644
--- a/cldk/analysis/java/neo4j/neo4j_backend.py
+++ b/cldk/analysis/java/neo4j/neo4j_backend.py
@@ -645,7 +645,7 @@ def application(self) -> JApplication:
return self._application
@cached_property
- def _idx(self) -> Tuple[Dict[str, JType], Dict[str, str], Dict[str, Tuple[JType, JCallable]]]:
+ def _idx(self) -> Tuple[Dict[str, JType], Dict[str, str], Dict[str, Tuple[JType, JCallable]], Dict[str, List[str]]]:
"""The containment tree flattened once: every type (top-level, nested, local/anonymous) by
its source-spelled qualified name, its file, and every callable by its ``can://`` id — the
join that turns a call-graph endpoint into the ``"."`` node key.
@@ -653,13 +653,16 @@ def _idx(self) -> Tuple[Dict[str, JType], Dict[str, str], Dict[str, Tuple[JType,
types: Dict[str, JType] = {}
file_of: Dict[str, str] = {}
callables: Dict[str, Tuple[JType, JCallable]] = {}
+ contested: Dict[str, List[str]] = {}
def add(t: JType, path: str) -> None:
name = t.qualified_name
if name in types:
- raise CodeanalyzerExecutionException(duplicate_type_name(name))
- types[name] = t
- file_of[name] = path
+ # See JCodeanalyzer._add_type: recorded here, refused at the query.
+ contested.setdefault(name, [file_of[name]]).append(path)
+ else:
+ types[name] = t
+ file_of[name] = path
for c in t.callables.values():
callables[c.id] = (t, c)
for local in c.types.values():
@@ -670,7 +673,7 @@ def add(t: JType, path: str) -> None:
for path, unit in self._application.symbol_table.items():
for t in unit.types.values():
add(t, path)
- return types, file_of, callables
+ return types, file_of, callables, contested
@property
def _types(self) -> Dict[str, JType]:
@@ -684,6 +687,10 @@ def _file_of(self) -> Dict[str, str]:
def _callables(self) -> Dict[str, Tuple[JType, JCallable]]:
return self._idx[2]
+ @property
+ def _contested(self) -> Dict[str, List[str]]:
+ return self._idx[3]
+
# -----[ the addressing surface (leg 3b) — the three facts the shared implementation needs ]-----
#: The one statement leg 3b's Task 1 adds. Anchored on the **bare** ``:JBodyNode`` label and a
#: per-callable id prefix, which is the narrowest predicate on this surface.
@@ -1325,6 +1332,7 @@ def get_all_classes(self) -> Dict[str, JType]:
return dict(self._types)
def get_class(self, qualified_class_name: str) -> JType | None:
+ self._refuse_if_contested(qualified_class_name)
return self._types.get(qualified_class_name)
def get_all_methods_in_application(self) -> Dict[str, Dict[str, JCallable]]:
diff --git a/tests/analysis/java/test_java_duplicate_fqn.py b/tests/analysis/java/test_java_duplicate_fqn.py
new file mode 100644
index 0000000..f4b29d3
--- /dev/null
+++ b/tests/analysis/java/test_java_duplicate_fqn.py
@@ -0,0 +1,123 @@
+################################################################################
+# Copyright IBM Corporation 2026
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+"""Duplicate qualified names must not make the whole application unloadable (#420).
+
+A monorepo whose services vendor a shared internal library puts several copies of one package
+into one analysis, so two compilation units legitimately declare one qualified name. The analyzer
+emits both, with distinct ``can://`` ids. The SDK used to raise while *flattening* that tree, which
+meant no query at all was available — including the overwhelming majority naming no duplicated
+type. These pin the ambiguity to the queries it actually affects.
+"""
+
+import json
+
+import pytest
+
+from cldk.analysis.java.codeanalyzer.codeanalyzer import JCodeanalyzer
+from cldk.analysis.java.neo4j import JNeo4jBackend
+from cldk.models.java.models import JAnalysis
+from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException
+
+VENDORED = "vendored/"
+
+
+def _duplicate_one_module(analysis_json: str):
+ """Copy one module to a second path, keeping every name it declares and rewriting only its
+ ``can://`` ids — exactly what a vendored copy of a library looks like to the analyzer."""
+ doc = json.loads(analysis_json)
+ table = doc["application"]["symbol_table"]
+ source_key = next(k for k, m in table.items() if m.get("types"))
+ # Rewriting the key inside the serialized module rewrites the module id and every type and
+ # callable id beneath it in one pass, so the copy is id-distinct and name-identical.
+ copy = json.loads(json.dumps(table[source_key]).replace(source_key, VENDORED + source_key))
+ table[VENDORED + source_key] = copy
+ # `types` is keyed by simple name; the qualified name the index keys on is package-qualified.
+ module = table[source_key]
+ fqn = f"{module['package']}.{next(iter(module['types']))}"
+ return json.dumps(doc), source_key, fqn
+
+
+@pytest.fixture
+def duplicated(analysis_json):
+ payload, source_key, fqn = _duplicate_one_module(analysis_json)
+ return payload, source_key, fqn
+
+
+def _in_memory(payload: str) -> JCodeanalyzer:
+ """A JCodeanalyzer over a seeded application, bypassing the analyzer subprocess that
+ ``__init__`` would otherwise drive."""
+ backend = JCodeanalyzer.__new__(JCodeanalyzer)
+ backend.application = JAnalysis.model_validate_json(payload).application
+ backend._call_graph = None
+ backend._sdg_cache = None
+ backend._index()
+ return backend
+
+
+def _neo4j(payload: str) -> JNeo4jBackend:
+ backend = JNeo4jBackend.__new__(JNeo4jBackend)
+ backend.application_name = "daytrader8"
+ backend.__dict__["_application"] = JAnalysis.model_validate_json(payload).application
+ return backend
+
+
+def test_an_application_carrying_duplicate_qualified_names_still_loads(duplicated):
+ payload, _, _ = duplicated
+ # Flattening must not refuse: the duplication is in the source tree, and every other name in
+ # the application is perfectly answerable.
+ _in_memory(payload)
+
+
+def test_a_name_declared_once_answers_as_it_always_did(duplicated):
+ payload, source_key, duplicated_fqn = duplicated
+ backend = _in_memory(payload)
+ table = json.loads(payload)["application"]["symbol_table"]
+ unambiguous = next(
+ f"{module['package']}.{name}"
+ for key, module in table.items()
+ if not key.startswith(VENDORED) and key != source_key and module.get("package")
+ for name in (module.get("types") or {})
+ )
+ assert backend.get_class(unambiguous) is not None
+
+
+def test_the_contested_name_raises_at_the_query_and_names_the_competing_files(duplicated):
+ payload, source_key, duplicated_fqn = duplicated
+ backend = _in_memory(payload)
+ with pytest.raises(CodeanalyzerExecutionException) as excinfo:
+ backend.get_class(duplicated_fqn)
+ message = str(excinfo.value)
+ assert duplicated_fqn in message
+ # The reader needs to know WHICH copies collided, or they cannot act on it.
+ assert source_key in message and VENDORED + source_key in message
+
+
+def test_every_copy_stays_addressable_by_its_own_can_id(duplicated):
+ payload, source_key, duplicated_fqn = duplicated
+ backend = _in_memory(payload)
+ # Nothing is dropped: ids are distinct per copy, so both copies' callables remain indexed.
+ ids = [cid for cid in backend._callables if VENDORED + source_key in cid]
+ assert ids, "the vendored copy's callables must still be reachable by id"
+
+
+def test_the_neo4j_backend_agrees_with_the_in_memory_one(duplicated):
+ payload, source_key, duplicated_fqn = duplicated
+ backend = _neo4j(payload)
+ # Building the index must not raise here either, and the same query must fail the same way.
+ backend._idx
+ with pytest.raises(CodeanalyzerExecutionException):
+ backend.get_class(duplicated_fqn)