From 8529a90c760c9fa472e1b8bce0bb6657c0beef9e Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 11:06:43 -0400 Subject: [PATCH 1/3] fix(java): honest Optional lookups; no crash on miss (#248) get_method/get_class/get_java_file fell off the end on a miss under non-Optional annotations (-> JCallable, -> JType), and get_method_parameters dereferenced that implicit None unconditionally, raising AttributeError on any typo'd class or signature. - Annotate get_method/get_class/get_java_file Optional on both the JCodeanalyzer and JNeo4jBackend implementations, with an explicit return None on miss. - get_method_parameters and get_comments_in_a_method now return [] on a miss instead of crashing. - Guard the two unguarded internal get_method consumers in the symbol-table call-graph construction path (target method and the per-class enumeration loop) so a miss mid-construction skips the entry instead of raising; behavior for found entries is unchanged. Miss-shape semantics of get_all_callers/get_all_callees (the bare {}) are out of scope here; that's the 2.0.0 batch issue (#249). --- cldk/analysis/java/backend.py | 14 +- .../java/codeanalyzer/codeanalyzer.py | 31 ++-- cldk/analysis/java/neo4j/neo4j_backend.py | 12 +- tests/analysis/java/test_java_analysis.py | 164 ++++++++++++++++++ .../java/test_java_neo4j_lookup_miss.py | 83 +++++++++ 5 files changed, 283 insertions(+), 21 deletions(-) create mode 100644 tests/analysis/java/test_java_neo4j_lookup_miss.py diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index b969b72..4e8ec58 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -76,8 +76,8 @@ def get_compilation_units(self) -> List[JCompilationUnit]: """All compilation units.""" @abstractmethod - def get_java_file(self, qualified_class_name: str) -> str: - """The file path declaring a class.""" + def get_java_file(self, qualified_class_name: str) -> str | None: + """The file path declaring a class. ``None`` if the class is not found.""" @abstractmethod def get_java_compilation_unit(self, file_path: str) -> JCompilationUnit: @@ -114,8 +114,8 @@ def get_all_classes(self) -> Dict[str, JType]: """Every class, keyed by qualified name.""" @abstractmethod - def get_class(self, qualified_class_name: str) -> JType: - """A single class by qualified name.""" + def get_class(self, qualified_class_name: str) -> JType | None: + """A single class by qualified name. ``None`` if not found.""" @abstractmethod def get_all_sub_classes(self, qualified_class_name: str) -> Dict[str, JType]: @@ -142,12 +142,12 @@ def get_all_methods_in_class(self, qualified_class_name: str) -> Dict[str, JCall """The methods of a class.""" @abstractmethod - def get_method(self, qualified_class_name: str, method_signature: str) -> JCallable: - """A single method of a class.""" + def get_method(self, qualified_class_name: str, method_signature: str) -> JCallable | None: + """A single method of a class. ``None`` if not found.""" @abstractmethod def get_method_parameters(self, qualified_class_name: str, method_signature: str) -> List[JCallableParameter]: - """The parameters of a method.""" + """The parameters of a method. Empty list if the method is not found.""" @abstractmethod def get_all_constructors(self, qualified_class_name: str) -> Dict[str, JCallable]: diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index 300917c..ce9ee1d 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -458,21 +458,22 @@ def get_all_classes(self) -> Dict[str, JType]: class_dict.update(v.type_declarations) return class_dict - def get_class(self, qualified_class_name) -> JType: + def get_class(self, qualified_class_name) -> JType | None: """Should return a class given the qualified class name. Args: qualified_class_name (str): The qualified name of the class. Returns: - JType: A class for the given qualified class name. + JType | None: A class for the given qualified class name, or None if not found. """ symtab = self.get_symbol_table() for _, v in symtab.items(): if qualified_class_name in v.type_declarations.keys(): return v.type_declarations.get(qualified_class_name) + return None - def get_method(self, qualified_class_name, method_signature) -> JCallable: + def get_method(self, qualified_class_name, method_signature) -> JCallable | None: """Should return a method given the qualified method name. Args: @@ -480,7 +481,7 @@ def get_method(self, qualified_class_name, method_signature) -> JCallable: method_signature (str): The signature of the method. Returns: - JCallable: A method for the given qualified method name. + JCallable | None: A method for the given qualified method name, or None if not found. """ symtab = self.get_symbol_table() for v in symtab.values(): @@ -489,6 +490,7 @@ def get_method(self, qualified_class_name, method_signature) -> JCallable: for cd in ci.callable_declarations.keys(): if cd == method_signature: return ci.callable_declarations[cd] + return None def get_method_parameters(self, qualified_class_name, method_signature) -> List[JCallableParameter]: """Should return a dictionary of method parameters given the qualified class name and method signature. @@ -498,9 +500,11 @@ def get_method_parameters(self, qualified_class_name, method_signature) -> List[ method_signature (str): The signature of the method. Returns: - Dict[str, str]: A dictionary of method parameters for the given qualified class name and method signature. + List[JCallableParameter]: The method parameters for the given qualified class name and method + signature. Empty list if the method is not found. """ - return self.get_method(qualified_class_name, method_signature).parameters + method = self.get_method(qualified_class_name, method_signature) + return method.parameters if method is not None else [] def get_parameters_from_callable(self, callable: JCallable) -> List[JCallableParameter]: """Should return a dictionary of method parameters given the callable. @@ -513,19 +517,20 @@ def get_parameters_from_callable(self, callable: JCallable) -> List[JCallablePar """ return callable.parameters - def get_java_file(self, qualified_class_name) -> str: + def get_java_file(self, qualified_class_name) -> str | None: """Should return java file name given the qualified class name. Args: qualified_class_name (str): The qualified name of the class. Returns: - str: Java file name containing the given qualified class. + str | None: Java file name containing the given qualified class, or None if not found. """ symtab = self.get_symbol_table() for k, v in symtab.items(): if (qualified_class_name) in v.type_declarations.keys(): return k + return None def get_compilation_units(self) -> List[JCompilationUnit]: """Get all the compilation units in the symbol table. @@ -736,9 +741,15 @@ def __raw_call_graph_using_symbol_table_target_method(self, target_class_name: s if cg is None: cg = [] target_method_details = self.get_method(qualified_class_name=target_class_name, method_signature=target_method_signature) + if target_method_details is None: + # The target method doesn't exist, so no edges into it can be constructed. + return cg for class_name in self.get_all_classes(): for method in self.get_all_methods_in_class(qualified_class_name=class_name): method_details = self.get_method(qualified_class_name=class_name, method_signature=method) + if method_details is None: + # The symbol table momentarily disagreed with itself; skip this entry. + continue for call_site in method_details.call_sites: source_method_details = None source_class = "" @@ -1078,10 +1089,10 @@ def get_comments_in_a_method(self, qualified_class_name: str, method_signature: method_signature (str): Signature of the method. Returns: - List[str]: List of comments in the method. + List[str]: List of comments in the method. Empty list if the method is not found. """ callable = self.get_method(qualified_class_name, method_signature) - return callable.comments + return callable.comments if callable is not None else [] def get_comments_in_a_class(self, qualified_class_name: str) -> List[JComment]: """Get all comments in a class. diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 6eed1a2..297967a 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -453,26 +453,30 @@ def get_all_classes(self) -> Dict[str, JType]: class_dict.update(v.type_declarations) return class_dict - def get_class(self, qualified_class_name) -> JType: + def get_class(self, qualified_class_name) -> JType | None: for v in self.get_symbol_table().values(): if qualified_class_name in v.type_declarations.keys(): return v.type_declarations.get(qualified_class_name) + return None - def get_method(self, qualified_class_name, method_signature) -> JCallable: + def get_method(self, qualified_class_name, method_signature) -> JCallable | None: for v in self.get_symbol_table().values(): if qualified_class_name in v.type_declarations.keys(): ci = v.type_declarations[qualified_class_name] for cd in ci.callable_declarations.keys(): if cd == method_signature: return ci.callable_declarations[cd] + return None def get_method_parameters(self, qualified_class_name, method_signature) -> List[JCallableParameter]: - return self.get_method(qualified_class_name, method_signature).parameters + method = self.get_method(qualified_class_name, method_signature) + return method.parameters if method is not None else [] - def get_java_file(self, qualified_class_name) -> str: + def get_java_file(self, qualified_class_name) -> str | None: for k, v in self.get_symbol_table().items(): if qualified_class_name in v.type_declarations.keys(): return k + return None def get_all_methods_in_class(self, qualified_class_name) -> Dict[str, JCallable]: ci = self.get_class(qualified_class_name) diff --git a/tests/analysis/java/test_java_analysis.py b/tests/analysis/java/test_java_analysis.py index 2bb6c8b..e69e009 100644 --- a/tests/analysis/java/test_java_analysis.py +++ b/tests/analysis/java/test_java_analysis.py @@ -1086,3 +1086,167 @@ def test_get_all_docstrings(test_fixture, analysis_json): assert isinstance(doc, JComment) if doc.content: print(f"Docstring: {doc.content}") + + +# -------------------------------------------------------------------------------------------- +# Miss-path tests (#248): lookups must return None/[] honestly on a miss, never crash. +# -------------------------------------------------------------------------------------------- + + +def test_get_class_miss_returns_none(test_fixture, analysis_json): + """A qualified class name that doesn't exist should return None, not fall off the end.""" + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + assert java_analysis.get_class("com.example.NoSuchClass") is None + + +def test_get_method_miss_returns_none(test_fixture, analysis_json): + """A method signature that doesn't exist should return None, not fall off the end.""" + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + # Known class, typo'd signature. + assert java_analysis.get_method("com.ibm.websphere.samples.daytrader.util.Log", "noSuchMethod()") is None + # Unknown class altogether. + assert java_analysis.get_method("com.example.NoSuchClass", "trace(java.lang.String)") is None + + +def test_get_java_file_miss_returns_none(test_fixture, analysis_json): + """A qualified class name that doesn't exist should return None, not fall off the end.""" + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + assert java_analysis.get_java_file("com.example.NoSuchClass") is None + + +def test_get_method_parameters_miss_returns_empty_list(test_fixture, analysis_json): + """get_method_parameters must not crash with AttributeError when the method is missing. + + Before the fix, this raised: AttributeError: 'NoneType' object has no attribute 'parameters'. + """ + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + assert java_analysis.get_method_parameters("com.ibm.websphere.samples.daytrader.util.Log", "noSuchMethod()") == [] + assert java_analysis.get_method_parameters("com.example.NoSuchClass", "trace(java.lang.String)") == [] + + +def test_get_comments_in_a_method_miss_returns_empty_list(test_fixture, analysis_json): + """get_comments_in_a_method must not crash with AttributeError when the method is missing.""" + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + assert java_analysis.backend.get_comments_in_a_method("com.ibm.websphere.samples.daytrader.util.Log", "noSuchMethod()") == [] + + +def test_call_graph_target_method_miss_mid_construction_no_crash(test_fixture, analysis_json): + """A get_method miss for the *target* method of a symbol-table call graph must not crash. + + Exercises JCodeanalyzer.__raw_call_graph_using_symbol_table_target_method (codeanalyzer.py:738), + reached through the public get_all_callers(using_symbol_table=True) path. + """ + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + result = java_analysis.backend.get_all_callers( + target_class_name="com.ibm.websphere.samples.daytrader.util.Log", + target_method_signature="noSuchMethod()", + using_symbol_table=True, + ) + assert result == {} + + +def test_call_graph_source_method_miss_mid_construction_no_crash(test_fixture, analysis_json): + """A get_method miss for a *candidate source* method mid-construction must be skipped, not crash. + + Exercises codeanalyzer.py:741 (and the crash it guards at the old :742 `.call_sites` dereference) + by making a single (class, signature) pair momentarily miss while the target method is real, + simulating a symbol table that disagrees with itself mid-construction. + """ + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + backend = java_analysis.backend + original_get_method = backend.get_method + flaky_class, flaky_signature = "com.ibm.websphere.samples.daytrader.util.Log", "log(java.lang.String)" + + def flaky_get_method(qualified_class_name, method_signature): + if qualified_class_name == flaky_class and method_signature == flaky_signature: + return None + return original_get_method(qualified_class_name, method_signature) + + backend.get_method = flaky_get_method + try: + # Real target method; a real class/method pair in the enumeration loop is simulated missing. + result = backend.get_all_callers( + target_class_name="com.ibm.websphere.samples.daytrader.util.Log", + target_method_signature="trace(java.lang.String)", + using_symbol_table=True, + ) + finally: + backend.get_method = original_get_method + + assert isinstance(result, dict) diff --git a/tests/analysis/java/test_java_neo4j_lookup_miss.py b/tests/analysis/java/test_java_neo4j_lookup_miss.py new file mode 100644 index 0000000..b27abf1 --- /dev/null +++ b/tests/analysis/java/test_java_neo4j_lookup_miss.py @@ -0,0 +1,83 @@ +################################################################################ +# 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. +################################################################################ + +"""Miss-path unit tests for JNeo4jBackend lookups (#248). + +These do not need a live Neo4j server: :class:`JNeo4jBackend` only needs ``self.application`` +populated to answer ``get_class``/``get_method``/``get_java_file``/``get_method_parameters`` (see +``get_symbol_table`` -> ``self.application.symbol_table``), so we bypass ``__init__`` (which opens a +driver connection) and seed ``.application`` directly from the same ``analysis.json`` fixture the +in-memory :class:`JCodeanalyzer` tests use — its shape is exactly the ``JApplication`` constructor's +kwargs (mirrors ``JCodeanalyzer._init_japplication``). +""" + +import json + +from cldk.analysis.java.neo4j import JNeo4jBackend +from cldk.models.java.models import JApplication, JCallable, JType + +_LOG_CLASS = "com.ibm.websphere.samples.daytrader.util.Log" +_LOG_TRACE_METHOD = "trace(java.lang.String)" + + +def _backend_from_analysis_json(analysis_json: str) -> JNeo4jBackend: + backend = JNeo4jBackend.__new__(JNeo4jBackend) + backend.application = JApplication(**json.loads(analysis_json)) + backend.analysis_level = "call_graph" if backend.application.call_graph else "symbol_table" + backend.call_graph = None + return backend + + +def test_get_class_miss_returns_none(analysis_json): + backend = _backend_from_analysis_json(analysis_json) + assert backend.get_class("com.example.NoSuchClass") is None + + +def test_get_method_miss_returns_none(analysis_json): + backend = _backend_from_analysis_json(analysis_json) + # Known class, typo'd signature. + assert backend.get_method(_LOG_CLASS, "noSuchMethod()") is None + # Unknown class altogether. + assert backend.get_method("com.example.NoSuchClass", _LOG_TRACE_METHOD) is None + + +def test_get_java_file_miss_returns_none(analysis_json): + backend = _backend_from_analysis_json(analysis_json) + assert backend.get_java_file("com.example.NoSuchClass") is None + + +def test_get_method_parameters_miss_returns_empty_list(analysis_json): + """Before the fix: AttributeError: 'NoneType' object has no attribute 'parameters'.""" + backend = _backend_from_analysis_json(analysis_json) + assert backend.get_method_parameters(_LOG_CLASS, "noSuchMethod()") == [] + assert backend.get_method_parameters("com.example.NoSuchClass", _LOG_TRACE_METHOD) == [] + + +def test_get_class_and_method_hit_behavior_unchanged(analysis_json): + """Sanity: the miss-path fix must not change hit behavior.""" + backend = _backend_from_analysis_json(analysis_json) + + the_class = backend.get_class(_LOG_CLASS) + assert the_class is not None + assert isinstance(the_class, JType) + + the_method = backend.get_method(_LOG_CLASS, _LOG_TRACE_METHOD) + assert the_method is not None + assert isinstance(the_method, JCallable) + assert the_method.declaration == "public static void trace(String message)" + + the_method_parameters = backend.get_method_parameters(_LOG_CLASS, _LOG_TRACE_METHOD) + assert len(the_method_parameters) == 1 From 46abdcbdee07d6970116ebeca783bbd49de82d5f Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 11:27:59 -0400 Subject: [PATCH 2/3] fix(java): guard remaining comment/call-graph lookups on miss (#248) Extends 8529a90 with the three remaining instances of the same unguarded-dereference-on-miss pattern: - JCodeanalyzer.get_comments_in_a_class: return [] instead of crashing when the class lookup misses. - JNeo4jBackend.get_comments_in_a_method / get_comments_in_a_class: same fix, mirrored on the Neo4j backend. - JNeo4jBackend.__raw_call_graph_using_symbol_table_target_method: guard the internal get_method lookups so a miss skips the entry (or returns the accumulated graph for the target-method case) instead of crashing, mirroring the guards already applied to JCodeanalyzer's sibling method. No behavior change on hits. --- .../java/codeanalyzer/codeanalyzer.py | 4 +- cldk/analysis/java/neo4j/neo4j_backend.py | 12 +++- tests/analysis/java/test_java_analysis.py | 20 +++++++ .../java/test_java_neo4j_lookup_miss.py | 56 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index ce9ee1d..fa7e171 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -1101,10 +1101,10 @@ def get_comments_in_a_class(self, qualified_class_name: str) -> List[JComment]: qualified_class_name (str): Qualified name of the class. Returns: - List[str]: List of comments in the class. + List[str]: List of comments in the class. Empty list if the class is not found. """ klass = self.get_class(qualified_class_name) - return klass.comments + return klass.comments if klass is not None else [] def get_comment_in_file(self, file_path: str) -> List[JComment]: """Get all comments in a file. diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index 297967a..d8ce3b3 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -567,9 +567,15 @@ def __raw_call_graph_using_symbol_table_target_method(self, target_class_name: s if cg is None: cg = [] target_method_details = self.get_method(qualified_class_name=target_class_name, method_signature=target_method_signature) + if target_method_details is None: + # The target method doesn't exist, so no edges into it can be constructed. + return cg for class_name in self.get_all_classes(): for method in self.get_all_methods_in_class(qualified_class_name=class_name): method_details = self.get_method(qualified_class_name=class_name, method_signature=method) + if method_details is None: + # The symbol table momentarily disagreed with itself; skip this entry. + continue for call_site in method_details.call_sites: source_method_details = None source_class = "" @@ -692,10 +698,12 @@ def get_all_delete_operations(self) -> List[Dict[str, Union[JType, JCallable, Li return self._crud(CRUDOperationType.DELETE) def get_comments_in_a_method(self, qualified_class_name: str, method_signature: str) -> List[JComment]: - return self.get_method(qualified_class_name, method_signature).comments + callable = self.get_method(qualified_class_name, method_signature) + return callable.comments if callable is not None else [] def get_comments_in_a_class(self, qualified_class_name: str) -> List[JComment]: - return self.get_class(qualified_class_name).comments + klass = self.get_class(qualified_class_name) + return klass.comments if klass is not None else [] def get_comment_in_file(self, file_path: str) -> List[JComment]: compilation_unit = self.get_symbol_table().get(file_path, None) diff --git a/tests/analysis/java/test_java_analysis.py b/tests/analysis/java/test_java_analysis.py index e69e009..1e7886e 100644 --- a/tests/analysis/java/test_java_analysis.py +++ b/tests/analysis/java/test_java_analysis.py @@ -1250,3 +1250,23 @@ def flaky_get_method(qualified_class_name, method_signature): backend.get_method = original_get_method assert isinstance(result, dict) + + +def test_get_comments_in_a_class_miss_returns_empty_list(test_fixture, analysis_json): + """get_comments_in_a_class must not crash with AttributeError when the class is missing. + + Before the fix, this raised: AttributeError: 'NoneType' object has no attribute 'comments'. + """ + + with patch("cldk.analysis.java.codeanalyzer.codeanalyzer.subprocess.run") as run_mock: + run_mock.side_effect = _write_java_output(analysis_json) + java_analysis = JavaAnalysis( + project_dir=test_fixture, + source_code=None, + backend=_BK, + analysis_level=AnalysisLevel.symbol_table, + target_files=None, + eager_analysis=False, + ) + + assert java_analysis.backend.get_comments_in_a_class("com.example.NoSuchClass") == [] diff --git a/tests/analysis/java/test_java_neo4j_lookup_miss.py b/tests/analysis/java/test_java_neo4j_lookup_miss.py index b27abf1..27d46b4 100644 --- a/tests/analysis/java/test_java_neo4j_lookup_miss.py +++ b/tests/analysis/java/test_java_neo4j_lookup_miss.py @@ -66,6 +66,62 @@ def test_get_method_parameters_miss_returns_empty_list(analysis_json): assert backend.get_method_parameters("com.example.NoSuchClass", _LOG_TRACE_METHOD) == [] +def test_get_comments_in_a_method_miss_returns_empty_list(analysis_json): + """Before the fix: AttributeError: 'NoneType' object has no attribute 'comments'.""" + backend = _backend_from_analysis_json(analysis_json) + assert backend.get_comments_in_a_method(_LOG_CLASS, "noSuchMethod()") == [] + + +def test_get_comments_in_a_class_miss_returns_empty_list(analysis_json): + """Before the fix: AttributeError: 'NoneType' object has no attribute 'comments'.""" + backend = _backend_from_analysis_json(analysis_json) + assert backend.get_comments_in_a_class("com.example.NoSuchClass") == [] + + +def test_call_graph_target_method_miss_mid_construction_no_crash(analysis_json): + """A get_method miss for the *target* method of a symbol-table call graph must not crash. + + Exercises JNeo4jBackend.__raw_call_graph_using_symbol_table_target_method, reached through the + public get_all_callers(using_symbol_table=True) path. Mirrors the JCodeanalyzer fix (#248). + """ + backend = _backend_from_analysis_json(analysis_json) + + result = backend.get_all_callers( + target_class_name=_LOG_CLASS, + target_method_signature="noSuchMethod()", + using_symbol_table=True, + ) + assert result == {} + + +def test_call_graph_source_method_miss_mid_construction_no_crash(analysis_json): + """A get_method miss for a *candidate source* method mid-construction must be skipped, not crash. + + Makes a single (class, signature) pair momentarily miss while the target method is real, + simulating a symbol table that disagrees with itself mid-construction. + """ + backend = _backend_from_analysis_json(analysis_json) + original_get_method = backend.get_method + flaky_class, flaky_signature = _LOG_CLASS, "log(java.lang.String)" + + def flaky_get_method(qualified_class_name, method_signature): + if qualified_class_name == flaky_class and method_signature == flaky_signature: + return None + return original_get_method(qualified_class_name, method_signature) + + backend.get_method = flaky_get_method + try: + result = backend.get_all_callers( + target_class_name=_LOG_CLASS, + target_method_signature=_LOG_TRACE_METHOD, + using_symbol_table=True, + ) + finally: + backend.get_method = original_get_method + + assert isinstance(result, dict) + + def test_get_class_and_method_hit_behavior_unchanged(analysis_json): """Sanity: the miss-path fix must not change hit behavior.""" backend = _backend_from_analysis_json(analysis_json) From b6d5bd58431510eabe41588d7d87766286043ecb Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 14 Jul 2026 11:49:24 -0400 Subject: [PATCH 3/3] fix(java): honest Optional annotations on the public facade; ABC docstring notes (#248) --- cldk/analysis/java/backend.py | 4 ++-- cldk/analysis/java/java_analysis.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 4e8ec58..defe61a 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -198,11 +198,11 @@ def get_comment_in_file(self, file_path: str) -> List[JComment]: @abstractmethod def get_comments_in_a_class(self, qualified_class_name: str) -> List[JComment]: - """The comments in a class.""" + """The comments in a class. Returns an empty list if the class is not found.""" @abstractmethod def get_comments_in_a_method(self, qualified_class_name: str, method_signature: str) -> List[JComment]: - """The comments in a method.""" + """The comments in a method. Returns an empty list if the method is not found.""" @abstractmethod def get_all_docstrings(self) -> List[Tuple[str, JComment]]: diff --git a/cldk/analysis/java/java_analysis.py b/cldk/analysis/java/java_analysis.py index 60707b1..230a6f9 100644 --- a/cldk/analysis/java/java_analysis.py +++ b/cldk/analysis/java/java_analysis.py @@ -610,7 +610,7 @@ def get_classes_by_criteria( class_dict[application_class] = all_classes[application_class] return class_dict - def get_class(self, qualified_class_name: str) -> JType: + def get_class(self, qualified_class_name: str) -> JType | None: """Return a specific class by its qualified name. Retrieves detailed information about a single class, including its @@ -632,7 +632,7 @@ def get_class(self, qualified_class_name: str) -> JType: return self.backend.get_class(qualified_class_name) - def get_method(self, qualified_class_name: str, qualified_method_name: str) -> JCallable: + def get_method(self, qualified_class_name: str, qualified_method_name: str) -> JCallable | None: """Return a specific method by class and method signature. Retrieves detailed information about a single method, including its @@ -676,7 +676,7 @@ def get_method_parameters(self, qualified_class_name: str, qualified_method_name """ return self.backend.get_method_parameters(qualified_class_name, qualified_method_name) - def get_java_file(self, qualified_class_name: str) -> str: + def get_java_file(self, qualified_class_name: str) -> str | None: """Return the file path containing a class with the given name. Given a qualified class name, returns the file path where that class