diff --git a/exploit-iq-models b/exploit-iq-models index 43de21f51..b60baa930 160000 --- a/exploit-iq-models +++ b/exploit-iq-models @@ -1 +1 @@ -Subproject commit 43de21f513538ef8580227584a6e609fe8e5029a +Subproject commit b60baa930431ef969bd4420ecc8af50f8402c2ce diff --git a/src/exploit_iq_commons/utils/chain_of_calls_retriever.py b/src/exploit_iq_commons/utils/chain_of_calls_retriever.py index 3cabd3b05..169e03ad7 100644 --- a/src/exploit_iq_commons/utils/chain_of_calls_retriever.py +++ b/src/exploit_iq_commons/utils/chain_of_calls_retriever.py @@ -95,7 +95,6 @@ def __init__(self, documents: List[Document], ecosystem: Ecosystem, manifest_pat used here to build the dependency tree for a more efficient lookup and search. """ - logger.debug("Creating Chain of Calls Retriever") logger.debug("Starting building Chain of Calls Retriever") self.ecosystem = ecosystem logger.debug("Chain of Calls Retriever - creating dependency tree") @@ -113,8 +112,8 @@ def __init__(self, documents: List[Document], ecosystem: Ecosystem, manifest_pat # Build a dependency tree using the dependency tree builder logic. tree = self.dependency_tree.builder.build_tree(manifest_path=manifest_path) for package, parents in tree.items(): - parents.extend([package]) - self.tree_dict[package] = parents + parents.append(package) + self.tree_dict[package] = list(dict.fromkeys(parents)) self.supported_packages = list(self.tree_dict.keys()) logger.debug("Chain of Calls Retriever - populating functions documents") @@ -159,8 +158,18 @@ def __init__(self, documents: List[Document], ecosystem: Ecosystem, manifest_pat self.functions_local_variables_index = self.language_parser.create_map_of_local_vars(self.documents_of_functions) logger.debug("Chain of Calls Retriever - after functions_local_variables_index") - if not self.language_parser.is_search_algo_dfs(): - self.sort_docs = self.__group_docs_by_pkg() + # Pre-index docs by package name for O(package_size) lookups instead of O(all_docs). + # sort_docs is used by BFS and by get_possible_docs for vendor-package filtering. + self.sort_docs = self.__group_docs_by_pkg() + # Pre-filter root-level docs to avoid scanning all documents in the root-package + # search path (sources_location_packages=False) of get_possible_docs. + self._root_docs = [doc for doc in self.documents if self.language_parser.is_root_package(doc)] + # Pre-index non-root docs by source path segments for fast vendor-package lookups. + # Maps each unique path component to the set of docs whose source contains it. + self._source_path_index: dict[str, list[Document]] = defaultdict(list) + for doc in self.documents: + if not self.language_parser.is_root_package(doc): + self._source_path_index[doc.metadata.get('source', '')].append(doc) def _resolve_tree_key(self, package: str, ctx: _SearchCtx) -> str | None: """Find the canonical tree_dict key for a package name. @@ -226,6 +235,17 @@ def __find_caller_function_dfs(self, document_function: Document, function_packa parents = self._get_parents(package_name, ctx) if parents: direct_parents.extend(parents) + # Search root-level parents first so the DFS finds root callers + # before exploring library-internal call chains. + root_first = [] + non_root = [] + for p in direct_parents: + pp = self._get_parents(p, ctx) + if pp and pp[0] == ROOT_LEVEL_SENTINEL: + root_first.append(p) + else: + non_root.append(p) + direct_parents = root_first + non_root function_name_to_search = self.language_parser.get_function_name(document_function) if not function_name_to_search: return None @@ -281,9 +301,6 @@ def __find_caller_function_dfs(self, document_function: Document, function_packa # match, and add it to exclusions so it will not consider it when backtracking in order to prevent cycles. if function_is_being_called: package_exclusions.append(doc) - # update index of last scanned package for backtracking - # hashed_value = calculate_hashable_string_for_function(function_file_name, function_name_to_search) - # self.last_visited_parent_package_indexes[hashed_value] = last_visited_package_index + package_index return doc # If didn't find a matching caller function document, returns None. @@ -292,38 +309,55 @@ def __find_caller_function_dfs(self, document_function: Document, function_packa def _is_doc_excluded(self, doc: Document, exclusions: list[Document]) -> bool: """ Checks if a document is in the exclusions list based on its - function name, function body and source metadata. + function body and source metadata. + Compares source first (cheap string compare) before falling back + to the more expensive content comparison. """ + if not exclusions: + return False doc_function_content = doc.page_content.strip() doc_source = doc.metadata.get('source').strip() for exclusion_doc in exclusions: - exclusion_function_content = exclusion_doc.page_content.strip() + # Compare source path first — cheaper and usually different exclusion_source = exclusion_doc.metadata.get('source').strip() - - if doc_function_content == exclusion_function_content and doc_source == exclusion_source: + if exclusion_source != doc_source: + continue + exclusion_function_content = exclusion_doc.page_content.strip() + if doc_function_content == exclusion_function_content: return True return False - # This helper method filter out irrelevant function ( that cannot be caller functions), it filter out all - # excluded functions, and all function that their body doesn't contain the target function name to search for. def get_possible_docs(self, function_name_to_search: str, package: str, exclusions: list[Document], sources_location_packages: bool, target_class_names: frozenset[str], method_exclusions: dict) -> (list[Document], bool): - if sources_location_packages: - filter_1 = [doc for doc in self.documents if package in doc.metadata.get('source') - and self.language_parser.is_function(doc) and - not self._is_doc_excluded(doc, exclusions)] - else: - filter_1 = [doc for doc in self.documents if self.language_parser.is_root_package(doc) and - (self.language_parser.is_function(doc) or self.language_parser.is_script_language()) and - not self._is_doc_excluded(doc, exclusions)] + """Filter documents to those that could be callers of function_name_to_search. + Applies the cheapest check first (search_token substring match) to + short-circuit before more expensive checks (is_function, _is_doc_excluded). + For root-package searches, uses pre-filtered _root_docs instead of scanning + all documents. + """ if not function_name_to_search: return [] - return [doc for doc in filter_1 if doc.page_content.__contains__(f"{function_name_to_search}(")] + search_token = f"{function_name_to_search}(" + if sources_location_packages: + # Use source path index to only scan docs whose path contains the package name, + # instead of iterating all documents. + candidates = [doc for path, docs in self._source_path_index.items() + if package in path for doc in docs] + return [doc for doc in candidates + if search_token in doc.page_content + and self.language_parser.is_function(doc) + and not self._is_doc_excluded(doc, exclusions)] + else: + # Use pre-filtered _root_docs to avoid scanning all documents + return [doc for doc in self._root_docs + if search_token in doc.page_content + and (self.language_parser.is_function(doc) or self.language_parser.is_script_language()) + and not self._is_doc_excluded(doc, exclusions)] def __find_caller_functions_bfs(self, document_function: Document, function_package: str, ctx: _SearchCtx) -> List[Document]: @@ -407,6 +441,7 @@ def __find_caller_functions_bfs(self, document_function: Document, function_pack documents_of_functions= self.documents_of_functions) + if found and self.language_parser.is_call_allowed( pkg_docs, doc, document_function): log_entries.append((file_name, func_name, function_name_to_search)) relevant_docs_to_search_in.append(doc) @@ -552,6 +587,7 @@ def get_relevant_documents(self, query: str) -> tuple[List[Document], bool]: matching_documents = [] standard_libs_cache = StandardLibraryCache.get_instance() # If it's a standard library package, then skip checking the package in dependency tree. + subpackage_filter = None if not standard_libs_cache.is_standard_library(package_name, self.ecosystem): # Check if input package is in dependency tree for package in self.tree_dict: @@ -559,12 +595,24 @@ def get_relevant_documents(self, query: str) -> tuple[List[Document], bool]: package_name = package found_package = True break + # Sub-package fallback: query may be a sub-path of a module in tree_dict + if not found_package: + for package in self.tree_dict: + suffix = self.language_parser.resolve_subpackage_to_module(package_name, package) + if suffix is not None: + subpackage_filter = suffix + logger.debug("Sub-package resolved: '%s' → module '%s' (filter='%s')", + package_name, package, subpackage_filter) + package_name = package + found_package = True + break # If it's , then create a document for it. if found_package: target_function_doc = self.__find_initial_function(function, package_name=package_name, documents=self.documents, ctx=ctx, - class_name=class_name) + class_name=class_name, + subpackage_filter=subpackage_filter) if not target_function_doc and self.language_parser.get_constructor_method_name(): target_function_doc = self.__find_initial_function(function_name=self.language_parser.get_constructor_method_name(), package_name=package_name, @@ -620,8 +668,6 @@ def get_relevant_documents(self, query: str) -> tuple[List[Document], bool]: matching_documents, ctx.found_path = self._breadth_first_search( matching_documents, target_function_doc, current_package_name, ctx) - # When the loop is finished, return list of documents ( path) and boolean indicating whether a path was - # found or not. return matching_documents, ctx.found_path def __determine_doc_package_name(self, target_function_doc, ctx: _SearchCtx): @@ -645,7 +691,8 @@ def __determine_doc_package_name(self, target_function_doc, ctx: _SearchCtx): return fallback def __find_initial_function(self, function_name: str, package_name: str, documents: list[Document], - ctx: _SearchCtx, class_name: str = None) -> Document: + ctx: _SearchCtx, class_name: str = None, + subpackage_filter: str | None = None) -> Document: if self.language_parser.is_search_algo_dfs(): pkg_docs = documents @@ -657,6 +704,14 @@ def __find_initial_function(self, function_name: str, package_name: str, documen relevant_docs = [doc for doc in relevant_docs if doc.page_content.endswith( f'{self.language_parser.get_comment_line_notation()}(class: {class_name})')] + if subpackage_filter: + pre_count = len(relevant_docs) + relevant_docs = [ + doc for doc in relevant_docs + if subpackage_filter in doc.metadata.get("source", "") + ] + logger.debug("Sub-package filter '%s': %d → %d docs", subpackage_filter, pre_count, len(relevant_docs)) + package_exclusions = ctx.exclusions[package_name] #for index, document in enumerate(get_functions_for_package(package_name, relevant_docs, language_parser)): from itertools import chain @@ -724,4 +779,4 @@ def function_called_from_caller_body(self, document: Document, function_to_searc flags=re.IGNORECASE | re.MULTILINE)) def document_belongs_to_package(self, document: Document, package_name: str) -> bool: - return self.language_parser.is_a_package(package_name, document) \ No newline at end of file + return self.language_parser.is_a_package(package_name, document) diff --git a/src/exploit_iq_commons/utils/functions_parsers/javascript_functions_parser.py b/src/exploit_iq_commons/utils/functions_parsers/javascript_functions_parser.py index e2b376128..883cf99fc 100644 --- a/src/exploit_iq_commons/utils/functions_parsers/javascript_functions_parser.py +++ b/src/exploit_iq_commons/utils/functions_parsers/javascript_functions_parser.py @@ -65,6 +65,10 @@ def get_function_name(self, function: Document) -> str: # the first '{' is inside the params, not the body start. # Extend header to include '=>' only when followed by '{' or end (top-level arrow). arrow_header = header + # Cap content-level regex searches to avoid catastrophic backtracking on + # huge functions (e.g. 715KB switch statements in graphemer). Function + # names are always in the first few lines. + content_head = content[:2000] if len(content) > 2000 else content if body_start != -1 and not header.rstrip().endswith(')') and not re.match(r'(?:(?:async|static|get|set)\s+)*[\w$]+\s*\(', header): search_from = body_start while search_from < len(content): @@ -116,23 +120,23 @@ def get_function_name(self, function: Document) -> str: if match: return match.group(1) - match = re.search(r'^([\w$]+)\s*:\s*(?:async\s+)?[\w$]+\s*=>', content, re.MULTILINE) + match = re.search(r'^([\w$]+)\s*:\s*(?:async\s+)?[\w$]+\s*=>', content_head, re.MULTILINE) if match: return match.group(1) - match = re.search(r'^([\w$]+)\s*:\s*(?:async\s+)?function\s*\(', content, re.MULTILINE) + match = re.search(r'^([\w$]+)\s*:\s*(?:async\s+)?function\s*\(', content_head, re.MULTILINE) if match: return match.group(1) - match = re.search(r"""^["']([\w$.!-]+)["']\s*[:(\s]""", content, re.MULTILINE) + match = re.search(r"""^["']([\w$.!-]+)["']\s*[:(\s]""", content_head, re.MULTILINE) if match: return match.group(1) - match = re.search(r'^\s*\*?\s*(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\[([^\]]+)\]\s*\(', content, re.MULTILINE) + match = re.search(r'^\s*\*?\s*(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\[([^\]]+)\]\s*\(', content_head, re.MULTILINE) if match: return match.group(1) - for match in re.finditer(r'(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([\w$]+)\s*[<(]', content): + for match in re.finditer(r'(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([\w$]+)\s*[<(]', content_head): if match.group(1) not in _JS_KEYWORDS: return match.group(1) @@ -243,7 +247,6 @@ def search_for_called_function(self, caller_function: Document, callee_function_ functions_local_variables_index: dict[str, dict], documents_of_functions: list[Document], type_inheritance: dict[Tuple[str, str], List[Tuple[str, str]]]=None) -> bool: - calls = self._get_function_calls(caller_function, callee_function_name, code_documents) if not calls: @@ -549,7 +552,7 @@ def normalize(line: str) -> str: return parts - def _parse_declarations(self, declaration_str: str, is_param: bool = False, is_multiline: bool = False) -> dict[str, dict]: + def _parse_declarations(self, declaration_str: str, is_param: bool = False) -> dict[str, dict]: """ Parse JavaScript variable/parameter declarations into a dict. @@ -642,7 +645,7 @@ def parse_all_type_struct_class_to_fields(self, types: list[Document], @staticmethod def _extract_class_name(class_code: str) -> str: """Extract class name from class definition.""" - match = re.search(r'class\s+(\w+)', class_code) + match = re.search(r'class\s+([\w$]+)', class_code) if match: return match.group(1) return '' @@ -730,7 +733,14 @@ def dir_name_for_3rd_party_packages(self) -> str: @classmethod def is_comment_line(cls, line: str) -> bool: stripped = line.strip() - return stripped.startswith('//') or stripped.startswith('/*') + if stripped.startswith('//') or stripped.startswith('/*'): + return True + if stripped.startswith('*'): + # Generator: *method(, * method(, *[Symbol.iterator]( + if re.match(r'\*\s*[\w$\[]+.*\(', stripped): + return False + return True + return False def is_doc_type(self, doc: Document) -> bool: if doc.metadata.get('content_type') != 'functions_classes': @@ -789,7 +799,7 @@ def _trace_variable_to_value(self, variable_name: str, lines: list[str], depth: return '' visited.add(variable_name) - string_pattern = rf'(?:const|let|var)\s+{re.escape(variable_name)}\s*=\s*([\'"`])([^\1]*?)\1' + string_pattern = rf'(?:const|let|var)\s+{re.escape(variable_name)}\s*=\s*([\'"`])(.*?)\1' var_pattern = rf'(?:const|let|var)\s+{re.escape(variable_name)}\s*=\s*(\w+)' for line in lines: diff --git a/src/exploit_iq_commons/utils/functions_parsers/lang_functions_parsers.py b/src/exploit_iq_commons/utils/functions_parsers/lang_functions_parsers.py index 65cc2c6d1..b54cea50b 100644 --- a/src/exploit_iq_commons/utils/functions_parsers/lang_functions_parsers.py +++ b/src/exploit_iq_commons/utils/functions_parsers/lang_functions_parsers.py @@ -146,6 +146,13 @@ def is_tree_key_match(self, package_from_doc: str, tree_key: str) -> bool: is_same_package. Go overrides with boundary-aware '/' matching.""" return self.is_same_package(package_from_doc, tree_key) + def resolve_subpackage_to_module(self, subpackage: str, tree_key: str) -> str | None: + """If *subpackage* is a sub-path of *tree_key*, return the relative + suffix (e.g. ``/encoding/protojson``). Returns ``None`` when there + is no parent-child relationship. Only meaningful for Go; other + ecosystems return ``None`` by default.""" + return None + @staticmethod def get_constructor_method_name(): return None diff --git a/src/vuln_analysis/tools/tests/test_transitive_code_search.py b/src/vuln_analysis/tools/tests/test_transitive_code_search.py index 092ce0348..e8d458be2 100644 --- a/src/vuln_analysis/tools/tests/test_transitive_code_search.py +++ b/src/vuln_analysis/tools/tests/test_transitive_code_search.py @@ -487,7 +487,6 @@ async def test_transitive_search_java_4(): assert len(list_path) > 1 assert 'src/main/java/io/cryostat' in list_path[-1] -@pytest.mark.skip @pytest.mark.asyncio async def test_java_script_transitive_search_1(): """Test that runs with a real repository""" @@ -511,7 +510,6 @@ async def test_java_script_transitive_search_1(): assert path_found == True assert len(list_path) == 2 -@pytest.mark.skip @pytest.mark.asyncio async def test_java_script_transitive_search_2(): """Test that runs with a real repository""" diff --git a/tests/test_java_script_extended_segmenter.py b/tests/test_java_script_extended_segmenter.py index 41dfef7a5..bc4922a6a 100644 --- a/tests/test_java_script_extended_segmenter.py +++ b/tests/test_java_script_extended_segmenter.py @@ -104,11 +104,27 @@ def test_code_simplification(test_case): def test_optional_chaining_preservation(): - """Test that optional chaining is preserved (tree-sitter handles it natively).""" - code = "const name = user?.profile?.name;" + """Test that tree-sitter parses optional chaining correctly.""" + code = """ +function processUser(user) { + return user?.profile?.name; +} +class UserManager { + getAddress() { + return this.user?.address?.street; + } +} +""" segmenter = ExtendedJavaScriptSegmenter(code) assert not segmenter.skip_file - assert "?." in segmenter.code + + functions = segmenter.extract_functions_classes() + # processUser function + UserManager class + getAddress method + assert len(functions) == 3 + + # Optional chaining syntax preserved in extracted function bodies + assert any("user?.profile?.name" in f for f in functions) + assert any("this.user?.address?.street" in f for f in functions) def test_invalid_js(): @@ -335,8 +351,12 @@ def test_parse_commonjs_module(): segmenter = ExtendedJavaScriptSegmenter(code) functions = segmenter.extract_functions_classes() - # Should extract functions - assert len(functions) >= 1 + # module.exports = { publicFunction() {} } uses object method shorthand + # in an assignment expression, which tree-sitter captures as + # expression_statement > assignment_expression, not as a variable_declarator. + # The query only matches objects inside lexical/variable declarations, so + # module.exports object methods are not extracted (known limitation). + assert len(functions) == 1 assert not segmenter.skip_file @@ -765,3 +785,418 @@ def test_app_level_bundle_still_skipped(self): """App-level .bundle.js files are build artifacts — should be skipped.""" assert ExtendedJavaScriptSegmenter.should_skip("assets/app.bundle.js") + +# ============================================================================= +# module.exports object method shorthand +# ============================================================================= + +class TestModuleExportsObjectMethodShorthand: + """module.exports = { fn() {} } uses assignment_expression, not variable_declarator. + The tree-sitter query only matches objects inside lexical/variable declarations, + so module.exports object methods are not extracted (known limitation).""" + + def test_module_exports_shorthand_not_extracted(self): + """module.exports = { publicFunction() {} } should NOT extract the method + because the object is inside an assignment_expression, not a variable_declarator.""" + code = """ +module.exports = { + publicFunction() { + return 'hello'; + } +}; +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + # The object method shorthand in assignment_expression is not captured. + # Only variable_declarator objects are matched by the tree-sitter query. + method_funcs = [f for f in functions if "publicFunction" in f] + assert len(method_funcs) == 0, ( + "module.exports = { fn() {} } should not be extracted (known limitation)" + ) + + def test_const_object_shorthand_extracted(self): + """const obj = { fn() {} } SHOULD extract the method (variable_declarator).""" + code = """ +const api = { + fetchData() { + return fetch('/api'); + } +}; +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + method_funcs = [f for f in functions if "fetchData" in f] + assert len(method_funcs) >= 1 + + +# ============================================================================= +# exports.foo = function(){} +# ============================================================================= + +class TestExportsDotFunctionCapture: + """Test exports.foo = function(){} capture via assign_func pattern.""" + + def test_exports_dot_function_captured(self): + """exports.foo = function(){} matches the assign_func pattern + (expression_statement > assignment_expression > function).""" + code = """ +exports.foo = function() { + return 'bar'; +}; +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + assert any("foo" in f for f in functions), ( + f"exports.foo = function() should be captured. Got: {functions}" + ) + + def test_exports_dot_arrow_captured(self): + """exports.bar = () => {} matches the assign_func pattern.""" + code = """ +exports.bar = () => { + return 'baz'; +}; +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + assert any("bar" in f for f in functions), ( + f"exports.bar = () => should be captured. Got: {functions}" + ) + + +# ============================================================================= +# wrapped function expressions +# ============================================================================= + +class TestWrappedFunctionExpressions: + """Test debounce(function(){}, 300) and similar wrapped patterns.""" + + def test_debounce_wrapped_function(self): + """const handler = debounce(function() {}, 300) should be captured.""" + code = """ +const handler = debounce(function() { + console.log('debounced'); +}, 300); +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + assert any("handler" in f or "debounce" in f for f in functions), ( + f"Wrapped function expression should be captured. Got: {functions}" + ) + + def test_throttle_wrapped_arrow(self): + """const handler = throttle(() => {}, 100) should be captured.""" + code = """ +const handler = throttle(() => { + console.log('throttled'); +}, 100); +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + assert any("handler" in f or "throttle" in f for f in functions), ( + f"Wrapped arrow function should be captured. Got: {functions}" + ) + + +# ============================================================================= +# make_line_comment, get_language, get_chunk_query +# ============================================================================= + +class TestSegmenterAccessors: + """Test simple accessor methods.""" + + def test_make_line_comment(self): + segmenter = ExtendedJavaScriptSegmenter("var x;") + assert segmenter.make_line_comment("test") == "// test" + + def test_get_language_returns_language(self): + segmenter = ExtendedJavaScriptSegmenter("var x;") + lang = segmenter.get_language() + assert lang is not None + + def test_get_chunk_query_returns_string(self): + segmenter = ExtendedJavaScriptSegmenter("var x;") + query = segmenter.get_chunk_query() + assert isinstance(query, str) + assert "function_declaration" in query + + +# ============================================================================= +# should_skip with coverage/ and .nyc_output/ +# ============================================================================= + +class TestShouldSkipCoverageDirectories: + """Test should_skip with coverage/ and .nyc_output/ directories.""" + + def test_coverage_dir_skipped(self): + """coverage/ is a build artifact directory — should be skipped.""" + assert ExtendedJavaScriptSegmenter.should_skip("coverage/lcov-report/index.js") + + def test_nyc_output_skipped(self): + """.nyc_output/ is Istanbul coverage output — should be skipped.""" + assert ExtendedJavaScriptSegmenter.should_skip(".nyc_output/data.js") + + def test_coverage_inside_node_modules_not_skipped(self): + """coverage/ inside node_modules is third-party source.""" + assert not ExtendedJavaScriptSegmenter.should_skip( + "node_modules/istanbul/coverage/utils.js" + ) + + +# ============================================================================= +# static methods, getters/setters, class inheritance +# ============================================================================= + +class TestClassFeatureExtraction: + """Test static methods, getters/setters, and class inheritance extraction.""" + + def test_static_methods_extracted(self): + code = """ +class Config { + static defaults() { + return {}; + } + + static fromFile(path) { + return new Config(); + } +} +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + method_funcs = [f for f in functions if "//(class: Config)" in f] + assert len(method_funcs) == 2 + assert any("defaults" in f for f in method_funcs) + assert any("fromFile" in f for f in method_funcs) + + def test_getters_setters_extracted(self): + code = """ +class User { + get name() { + return this._name; + } + + set name(value) { + this._name = value; + } +} +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + method_funcs = [f for f in functions if "//(class: User)" in f] + assert len(method_funcs) == 2 + assert any("get name" in f for f in method_funcs) + assert any("set name" in f for f in method_funcs) + + def test_class_inheritance_preserved(self): + """Class with extends should still have methods extracted.""" + code = """ +class Dog extends Animal { + bark() { + return 'woof'; + } + + fetch(item) { + return item; + } +} +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + method_funcs = [f for f in functions if "//(class: Dog)" in f] + assert len(method_funcs) == 2 + assert any("bark" in f for f in method_funcs) + assert any("fetch" in f for f in method_funcs) + + +# ============================================================================= +# is_valid() coverage +# ============================================================================= + +class TestIsValid: + """Test the is_valid() method which checks for parse errors.""" + + def test_valid_js_returns_true(self): + segmenter = ExtendedJavaScriptSegmenter("function hello() { return 42; }") + assert segmenter.is_valid() is True + + def test_syntax_error_returns_false(self): + segmenter = ExtendedJavaScriptSegmenter("function { }") + assert segmenter.is_valid() is False + + def test_unterminated_construct_returns_false(self): + segmenter = ExtendedJavaScriptSegmenter("class { method( { }") + assert segmenter.is_valid() is False + + def test_shebang_file_returns_false(self): + """skip_file is True for shebang files, so is_valid returns False.""" + segmenter = ExtendedJavaScriptSegmenter("#!/usr/bin/env node\nfunction main() {}") + assert segmenter.is_valid() is False + + +# ============================================================================= +# simplify_code with known function structure +# ============================================================================= + +class TestSimplifyCodeOutput: + """Test that simplify_code replaces function bodies with '// Code for:' markers.""" + + def test_simplify_replaces_each_function_with_marker(self): + code = "function hello() {\n console.log('Hello');\n console.log('World');\n}\n\nfunction goodbye() {\n return 'Bye';\n}" + segmenter = ExtendedJavaScriptSegmenter(code) + simplified = segmenter.simplify_code() + + lines = simplified.splitlines() + markers = [l for l in lines if l.startswith("// Code for:")] + assert len(markers) == 2 + assert any("hello" in m for m in markers) + assert any("goodbye" in m for m in markers) + + # Function body lines should be removed + assert "console.log" not in simplified + assert "return 'Bye'" not in simplified + + def test_simplify_preserves_non_function_code(self): + code = "const VERSION = '1.0';\n\nfunction process() {\n doWork();\n}\n\nconst AUTHOR = 'test';" + segmenter = ExtendedJavaScriptSegmenter(code) + simplified = segmenter.simplify_code() + + assert "VERSION" in simplified + assert "AUTHOR" in simplified + assert "// Code for:" in simplified + assert "doWork" not in simplified + + +# ============================================================================= +# _get_tree() caching +# ============================================================================= + +class TestGetTreeCaching: + """Test that _get_tree() returns the same cached object on repeat calls.""" + + def test_tree_is_cached(self): + segmenter = ExtendedJavaScriptSegmenter("var x = 1;") + tree1 = segmenter._get_tree() + tree2 = segmenter._get_tree() + assert tree1 is tree2 + + +# ============================================================================= +# should_skip with Windows backslash paths +# ============================================================================= + +class TestShouldSkipWindowsPaths: + """Test that backslash-separated paths are normalized before skip checks.""" + + def test_dist_backslash_skipped(self): + assert ExtendedJavaScriptSegmenter.should_skip("dist\\bundle.js") + + def test_node_modules_backslash_dist_not_skipped(self): + assert not ExtendedJavaScriptSegmenter.should_skip( + "node_modules\\lodash\\dist\\index.js" + ) + + def test_build_static_backslash_skipped(self): + assert ExtendedJavaScriptSegmenter.should_skip( + "build\\static\\js\\main.js" + ) + + +# ============================================================================= +# _extract_class_methods with nested classes +# ============================================================================= + +class TestNestedClassExtraction: + """Test _extract_class_methods behavior with nested class declarations.""" + + def test_class_nested_in_function_methods_not_individually_annotated(self): + """_walk_type only searches root-level children, so a class nested + inside a function is captured as part of the enclosing function chunk + but its methods are not separately annotated.""" + code = """ +function factory() { + class Inner { + innerMethod() { return 1; } + } + return new Inner(); +} +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + + # Only the top-level function is extracted as a chunk + assert len(functions) == 1 + assert "function factory" in functions[0] + # Inner class methods do NOT get //(class: Inner) annotation + inner_annotated = [f for f in functions if "//(class: Inner)" in f] + assert len(inner_annotated) == 0 + + def test_top_level_class_with_class_expression_in_method(self): + """Top-level class methods are annotated; a class expression returned + inside a method is not separately walked.""" + code = """ +class Outer { + createInner() { + return class InnerClass { + innerMethod() { return 42; } + }; + } + outerMethod() { + return 1; + } +} +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + + outer_methods = [f for f in functions if "//(class: Outer)" in f] + assert len(outer_methods) == 2 + assert any("createInner" in f for f in outer_methods) + assert any("outerMethod" in f for f in outer_methods) + + # InnerClass is not a root-level child, so no separate annotation + inner_methods = [f for f in functions if "//(class: InnerClass)" in f] + assert len(inner_methods) == 0 + + +# ============================================================================= +# _extract_object_methods with mixed property types +# ============================================================================= + +class TestObjectMethodsMixedProperties: + """Test that only function-like properties are extracted from object literals.""" + + def test_mixed_properties_only_functions_extracted(self): + """Non-function properties (string, number, boolean) should not appear + as extracted methods; only method shorthand, function expressions, + and arrow functions should.""" + code = """ +const config = { + name: 'John', + age: 30, + active: true, + greet() { + return 'hello'; + }, + format: function(data) { + return JSON.stringify(data); + }, + transform: (x) => x * 2 +}; +""" + segmenter = ExtendedJavaScriptSegmenter(code) + functions = segmenter.extract_functions_classes() + + method_funcs = [f for f in functions if "//(class: config)" in f] + # greet (method shorthand), format (function expr), transform (arrow) + assert len(method_funcs) == 3 + assert any("greet" in f for f in method_funcs) + assert any("format" in f for f in method_funcs) + assert any("transform" in f for f in method_funcs) + + # Non-function properties should not be extracted as methods + all_text = "\n".join(method_funcs) + assert "name: 'John'" not in all_text + assert "age: 30" not in all_text + assert "active: true" not in all_text + diff --git a/tests/test_javascript_functions_parser.py b/tests/test_javascript_functions_parser.py index fda6ea5bb..25df3b31e 100644 --- a/tests/test_javascript_functions_parser.py +++ b/tests/test_javascript_functions_parser.py @@ -4153,21 +4153,14 @@ class TestPrintCallHierarchyEmptyName: """print_call_hierarchy catches ValueError but doesn't check for empty string.""" def test_get_function_name_empty_string_handled(self, parser): - """Documents where get_function_name returns '' should not crash hierarchy.""" + """A comment-only document has no content_type metadata, so is_function + returns False and get_function_name raises ValueError.""" doc = Document( page_content="// just a comment block\n/* nothing here */", metadata={"source": "utils.js"} ) - try: - name = parser.get_function_name(doc) - except ValueError: - name = None - - # The bug: if get_function_name returns '' instead of raising, - # print_call_hierarchy formats it as (package=...,function=,depth=0) - # which is a meaningless entry in the call hierarchy. - # The fix in chain_of_calls_retriever.py guards against empty strings. - assert name is None or isinstance(name, str) + with pytest.raises(ValueError): + parser.get_function_name(doc) # ============================================================================= @@ -4317,18 +4310,16 @@ def test_prototype_inheritance(self, parser): } assert parser._is_subclass_of("ReadStream", "EventEmitter", docs) - def test_hierarchy_cache_reused(self, parser): - """Same code_documents dict should reuse cached hierarchy.""" + def test_hierarchy_consistent_across_calls(self, parser): + """Multiple _is_subclass_of calls with the same docs should produce + consistent results — verifying the hierarchy is correctly maintained.""" docs = { "a.js": Document(page_content="class A extends B { }", metadata={"source": "a.js"}) } - parser._is_subclass_of("A", "B", docs) - cache_key_1 = parser._class_hierarchy_cache_key - - parser._is_subclass_of("A", "C", docs) - cache_key_2 = parser._class_hierarchy_cache_key - - assert cache_key_1 == cache_key_2 + assert parser._is_subclass_of("A", "B", docs) + # Second call with same docs should still return correct results + assert parser._is_subclass_of("A", "B", docs) + assert not parser._is_subclass_of("A", "C", docs) def test_different_docs_rebuilds_cache(self, parser): docs1 = {"a.js": Document(page_content="class A extends B { }", metadata={"source": "a.js"})} @@ -4500,3 +4491,1234 @@ def test_alias_commonjs_chain(self, parser): f"Chained CommonJS alias orig→mid→local should resolve. Got: {calls}" ) + +# ============================================================================= +# is_exported_function +# ============================================================================= + +class TestIsExportedFunctionCoverage: + """Test is_exported_function with ES6 exports, CommonJS exports, and non-exported functions.""" + + def test_es6_export_function(self, parser): + """export function foo() {} should be detected as exported.""" + source = "node_modules/pkg/lib/utils.js" + func_doc = Document( + page_content="export function foo() { return 1; }", + metadata={"source": source, "content_type": "functions_classes"} + ) + full_sources = {source: Document(page_content="export function foo() { return 1; }", metadata={"source": source})} + assert parser.is_exported_function(func_doc, full_sources) + + def test_es6_export_default_function(self, parser): + """export default function should be detected as exported.""" + source = "node_modules/pkg/index.js" + func_doc = Document( + page_content="export default function bar() { return 2; }", + metadata={"source": source, "content_type": "functions_classes"} + ) + full_sources = {source: Document(page_content="export default function bar() { return 2; }", metadata={"source": source})} + assert parser.is_exported_function(func_doc, full_sources) + + def test_es6_named_export(self, parser): + """export { name } should be detected as exported.""" + source = "node_modules/pkg/utils.js" + func_doc = Document( + page_content="function helper() { return 3; }", + metadata={"source": source, "content_type": "functions_classes"} + ) + full_sources = {source: Document( + page_content="function helper() { return 3; }\nexport { helper };", + metadata={"source": source} + )} + assert parser.is_exported_function(func_doc, full_sources) + + def test_commonjs_module_exports(self, parser): + """module.exports = name should be detected as exported.""" + source = "node_modules/pkg/index.js" + func_doc = Document( + page_content="function main() { return 4; }", + metadata={"source": source, "content_type": "functions_classes"} + ) + full_sources = {source: Document( + page_content="function main() { return 4; }\nmodule.exports = main;", + metadata={"source": source} + )} + assert parser.is_exported_function(func_doc, full_sources) + + def test_commonjs_exports_dot(self, parser): + """exports.name = ... should be detected as exported.""" + source = "node_modules/pkg/lib.js" + func_doc = Document( + page_content="function util() { return 5; }", + metadata={"source": source, "content_type": "functions_classes"} + ) + full_sources = {source: Document( + page_content="function util() { return 5; }\nexports.util = util;", + metadata={"source": source} + )} + assert parser.is_exported_function(func_doc, full_sources) + + def test_non_exported_function(self, parser): + """Functions without any export syntax should NOT be detected as exported.""" + source = "node_modules/pkg/internal.js" + func_doc = Document( + page_content="function _private() { return 6; }", + metadata={"source": source, "content_type": "functions_classes"} + ) + full_sources = {source: Document( + page_content="function _private() { return 6; }", + metadata={"source": source} + )} + assert not parser.is_exported_function(func_doc, full_sources) + + def test_source_not_in_full_sources(self, parser): + """If source file is missing from documents_of_full_sources, return False.""" + func_doc = Document( + page_content="function orphan() {}", + metadata={"source": "missing.js", "content_type": "functions_classes"} + ) + assert not parser.is_exported_function(func_doc, {}) + + +# ============================================================================= +# _is_subclass_of +# ============================================================================= + +class TestIsSubclassOfCoverage: + """Test transitive inheritance, mixins, and prototype-based chains.""" + + def test_transitive_three_levels(self, parser): + """A extends B extends C — A should be subclass of C.""" + docs = { + "a.js": Document(page_content="class A extends B { }", metadata={"source": "a.js"}), + "b.js": Document(page_content="class B extends C { }", metadata={"source": "b.js"}), + } + assert parser._is_subclass_of("A", "C", docs) + + def test_mixin_inheritance(self, parser): + """class X extends Mixin(Base) — X should be subclass of both Mixin and Base.""" + docs = { + "a.js": Document(page_content="class X extends EventEmitter(Stream) { }", metadata={"source": "a.js"}), + } + assert parser._is_subclass_of("X", "EventEmitter", docs) + assert parser._is_subclass_of("X", "Stream", docs) + + def test_prototype_chain(self, parser): + """util.inherits(A, B) + util.inherits(B, C) — A should be subclass of C.""" + docs = { + "a.js": Document(page_content="util.inherits(A, B);", metadata={"source": "a.js"}), + "b.js": Document(page_content="util.inherits(B, C);", metadata={"source": "b.js"}), + } + assert parser._is_subclass_of("A", "C", docs) + + def test_mixed_es6_and_prototype(self, parser): + """class A extends B + util.inherits(B, C) — A is subclass of C.""" + docs = { + "a.js": Document(page_content="class A extends B { }", metadata={"source": "a.js"}), + "b.js": Document(page_content="util.inherits(B, C);", metadata={"source": "b.js"}), + } + assert parser._is_subclass_of("A", "C", docs) + + def test_object_create_prototype_chain(self, parser): + """Child.prototype = Object.create(Parent.prototype) — transitive check.""" + docs = { + "a.js": Document(page_content="X.prototype = Object.create(Y.prototype);", metadata={"source": "a.js"}), + "b.js": Document(page_content="Y.prototype = Object.create(Z.prototype);", metadata={"source": "b.js"}), + } + assert parser._is_subclass_of("X", "Z", docs) + + +# ============================================================================= +# create_map_of_local_vars +# ============================================================================= + +class TestCreateMapOfLocalVarsCoverage: + """Test with var, let, const declarations, destructuring, and function returns.""" + + def test_var_declaration(self, parser): + """var declarations should be captured in the local vars map.""" + doc = Document( + page_content="function foo() {\n var x = 10;\n}", + metadata={"source": "a.js", "content_type": "functions_classes"} + ) + result = parser.create_map_of_local_vars([doc]) + assert "foo@a.js" in result + assert "x" in result["foo@a.js"] + assert result["foo@a.js"]["x"]["value"] == "10" + + def test_let_declaration(self, parser): + """let declarations should be captured.""" + doc = Document( + page_content="function bar() {\n let y = 'hello';\n}", + metadata={"source": "b.js", "content_type": "functions_classes"} + ) + result = parser.create_map_of_local_vars([doc]) + assert "bar@b.js" in result + assert "y" in result["bar@b.js"] + + def test_const_declaration(self, parser): + """const declarations should be captured.""" + doc = Document( + page_content="function baz() {\n const z = new Map();\n}", + metadata={"source": "c.js", "content_type": "functions_classes"} + ) + result = parser.create_map_of_local_vars([doc]) + assert "baz@c.js" in result + assert "z" in result["baz@c.js"] + assert result["baz@c.js"]["z"]["type"] == "Map" + + def test_destructuring(self, parser): + """Destructured variables should be captured.""" + doc = Document( + page_content="function extract(obj) {\n const { a, b } = obj;\n}", + metadata={"source": "d.js", "content_type": "functions_classes"} + ) + result = parser.create_map_of_local_vars([doc]) + key = "extract@d.js" + assert key in result + assert "a" in result[key] + assert "b" in result[key] + + def test_parameters_captured(self, parser): + """Function parameters should be in the vars map with value 'parameter'.""" + doc = Document( + page_content="function greet(name, age) {\n return name;\n}", + metadata={"source": "e.js", "content_type": "functions_classes"} + ) + result = parser.create_map_of_local_vars([doc]) + key = "greet@e.js" + assert key in result + assert result[key]["name"]["value"] == "parameter" + assert result[key]["age"]["value"] == "parameter" + + def test_class_method_has_this(self, parser): + """Class methods should have 'this' in their local vars map.""" + doc = Document( + page_content="doStuff() {\n const x = 1;\n}\n//(class: MyService)", + metadata={"source": "f.js", "content_type": "functions_classes"} + ) + result = parser.create_map_of_local_vars([doc]) + key = "doStuff@f.js" + assert key in result + assert "this" in result[key] + assert result[key]["this"]["type"] == "MyService" + + +# ============================================================================= +# _build_class_hierarchy +# ============================================================================= + +class TestBuildClassHierarchyCoverage: + """Test hierarchy building with extends and prototype assignment.""" + + def test_extends_simple(self, parser): + docs = {"a.js": Document(page_content="class Dog extends Animal { }", metadata={"source": "a.js"})} + hierarchy = parser._build_class_hierarchy(docs) + assert "Dog" in hierarchy + assert hierarchy["Dog"][1] == "Animal" + + def test_prototype_assignment(self, parser): + docs = {"a.js": Document(page_content="Child.prototype = Object.create(Parent.prototype);", metadata={"source": "a.js"})} + hierarchy = parser._build_class_hierarchy(docs) + assert "Child" in hierarchy + assert hierarchy["Child"] == (None, "Parent") + + def test_setPrototypeOf(self, parser): + docs = {"a.js": Document(page_content="Object.setPrototypeOf(Sub.prototype, Super.prototype);", metadata={"source": "a.js"})} + hierarchy = parser._build_class_hierarchy(docs) + assert "Sub" in hierarchy + assert hierarchy["Sub"] == (None, "Super") + + def test_multiple_files(self, parser): + docs = { + "a.js": Document(page_content="class A extends B { }", metadata={"source": "a.js"}), + "b.js": Document(page_content="util.inherits(C, D);", metadata={"source": "b.js"}), + } + hierarchy = parser._build_class_hierarchy(docs) + assert "A" in hierarchy + assert "C" in hierarchy + assert hierarchy["A"][1] == "B" + assert hierarchy["C"][1] == "D" + + +# ============================================================================= +# _split_into_statements +# ============================================================================= + +class TestSplitIntoStatementsCoverage: + """Test multi-line statements, semicolons in strings, and arrow functions.""" + + def test_simple_semicolons(self, parser): + result = JavaScriptFunctionsParser._split_into_statements("let x = 1; let y = 2;") + assert any("x = 1" in s for s in result) + assert any("y = 2" in s for s in result) + + def test_semicolon_inside_string(self, parser): + """Semicolons inside strings should not split statements.""" + result = JavaScriptFunctionsParser._split_into_statements('let msg = "hello; world";') + joined = " ".join(result) + assert "hello; world" in joined + + def test_arrow_function(self, parser): + result = JavaScriptFunctionsParser._split_into_statements("const fn = (x) => { return x; };") + assert len(result) >= 1 + + def test_multiline_statement(self, parser): + code = "const obj = {\n a: 1,\n b: 2\n};" + result = JavaScriptFunctionsParser._split_into_statements(code) + assert len(result) >= 1 + + +# ============================================================================= +# _parse_declarations +# ============================================================================= + +class TestParseDeclarationsCoverage: + """Test rest params, destructuring, and defaults.""" + + def test_rest_params(self, parser): + result = parser._parse_declarations("...args", is_param=True) + assert "args" in result + assert result["args"]["type"] == "Array" + + def test_destructuring_object(self, parser): + result = parser._parse_declarations("{a, b} = obj", is_param=False) + assert "a" in result + assert "b" in result + + def test_destructuring_array(self, parser): + result = parser._parse_declarations("[x, y] = arr", is_param=False) + assert "x" in result + assert "y" in result + + def test_default_value(self, parser): + """Parameters with defaults (count = 0) keep the default as value, not 'parameter'.""" + result = parser._parse_declarations("count = 0", is_param=True) + assert "count" in result + assert result["count"]["value"] == "0" + + def test_param_without_default(self, parser): + """Parameters without defaults get value 'parameter'.""" + result = parser._parse_declarations("name", is_param=True) + assert "name" in result + assert result["name"]["value"] == "parameter" + + def test_new_expression_type(self, parser): + result = parser._parse_declarations("obj = new Date()", is_param=False) + assert "obj" in result + assert result["obj"]["type"] == "Date" + + def test_multiple_declarations(self, parser): + result = parser._parse_declarations("x = 1, y = 2", is_param=False) + assert "x" in result + assert "y" in result + + def test_rename_in_destructuring(self, parser): + """Destructuring with rename: {oldName: newName} should capture newName.""" + result = parser._parse_declarations("{original: renamed} = obj", is_param=False) + assert "renamed" in result + + +# ============================================================================= +# parse_all_type_struct_class_to_fields +# ============================================================================= + +class TestParseAllTypeStructClassToFieldsCoverage: + """Test JS class fields extraction.""" + + def test_class_with_fields(self, parser): + doc = Document( + page_content="class Config {\n host = 'localhost';\n port = 3000;\n\n constructor() {\n this.ready = false;\n }\n}", + metadata={"source": "config.js", "content_type": "functions_classes"} + ) + result = parser.parse_all_type_struct_class_to_fields([doc]) + assert ("Config", "config.js") in result + fields = result[("Config", "config.js")] + field_names = [f[0] for f in fields] + assert "host" in field_names + assert "port" in field_names + assert "ready" in field_names + + def test_non_class_document_skipped(self, parser): + doc = Document( + page_content="function foo() { return 1; }", + metadata={"source": "foo.js", "content_type": "functions_classes"} + ) + result = parser.parse_all_type_struct_class_to_fields([doc]) + assert len(result) == 0 + + def test_all_fields_have_type_any(self, parser): + doc = Document( + page_content="class Item {\n name = 'test';\n}", + metadata={"source": "item.js", "content_type": "functions_classes"} + ) + result = parser.parse_all_type_struct_class_to_fields([doc]) + for _name, _type in result[("Item", "item.js")]: + assert _type == "any" + + +# ============================================================================= +# _check_package_reexport +# ============================================================================= + +class TestCheckPackageReexportCoverage: + """Test index.js re-export detection.""" + + def test_reexport_from_index_js(self, parser): + """Function defined in subdir, exported from package index.js.""" + source = "node_modules/my-pkg/lib/utils.js" + full_sources = { + "node_modules/my-pkg/index.js": Document( + page_content="export { helper } from './lib/utils';", + metadata={"source": "node_modules/my-pkg/index.js"} + ) + } + assert parser._check_package_reexport("helper", source, full_sources) + + def test_no_reexport(self, parser): + """Function not re-exported from index.js.""" + source = "node_modules/my-pkg/lib/internal.js" + full_sources = { + "node_modules/my-pkg/index.js": Document( + page_content="export { main } from './main';", + metadata={"source": "node_modules/my-pkg/index.js"} + ) + } + assert not parser._check_package_reexport("internalOnly", source, full_sources) + + def test_no_index_file(self, parser): + """No index.js exists — should return False.""" + source = "node_modules/my-pkg/lib/utils.js" + assert not parser._check_package_reexport("helper", source, {}) + + +# ============================================================================= +# document_imports_package +# ============================================================================= + +class TestDocumentImportsPackageCoverage: + """JS override for import detection.""" + + def test_es6_import(self, parser): + doc = Document(page_content="import foo from 'lodash';", metadata={"source": "a.js"}) + result = parser.document_imports_package({"a.js": doc}, "lodash") + assert len(result) == 1 + + def test_commonjs_require(self, parser): + doc = Document(page_content="const x = require('express');", metadata={"source": "b.js"}) + result = parser.document_imports_package({"b.js": doc}, "express") + assert len(result) == 1 + + def test_no_import(self, parser): + doc = Document(page_content="function foo() {}", metadata={"source": "c.js"}) + result = parser.document_imports_package({"c.js": doc}, "lodash") + assert len(result) == 0 + + def test_es6_from_syntax(self, parser): + doc = Document(page_content="import { bar } from 'axios';", metadata={"source": "d.js"}) + result = parser.document_imports_package({"d.js": doc}, "axios") + assert len(result) == 1 + + +# ============================================================================= +# get_import_search_patterns +# ============================================================================= + +class TestGetImportSearchPatternsCoverage: + """Test import detection pattern generation.""" + + def test_patterns_count(self, parser): + """Should return 3 patterns: require, import-from, bare import.""" + patterns = parser.get_import_search_patterns("lodash") + assert len(patterns) == 3 + + def test_require_pattern_matches(self, parser): + patterns = parser.get_import_search_patterns("express") + require_pattern = patterns[0] + assert require_pattern.search("require('express')") + assert require_pattern.search('require("express/lib/router")') + + def test_import_from_pattern_matches(self, parser): + patterns = parser.get_import_search_patterns("lodash") + import_pattern = patterns[1] + assert import_pattern.search("import { map } from 'lodash'") + + def test_bare_import_pattern_matches(self, parser): + patterns = parser.get_import_search_patterns("polyfill") + bare_pattern = patterns[2] + assert bare_pattern.search("import 'polyfill'") + + +# ============================================================================= +# _is_position_inside_string_literal +# ============================================================================= + +class TestIsPositionInsideStringLiteralCoverage: + """Test string literal position detection.""" + + def test_outside_string(self): + assert not JavaScriptFunctionsParser._is_position_inside_string_literal("let x = 1;", 4) + + def test_inside_double_quotes(self): + line = 'let x = "hello world";' + # Position 12 is inside "hello world" + assert JavaScriptFunctionsParser._is_position_inside_string_literal(line, 12) + + def test_inside_single_quotes(self): + line = "let x = 'hello world';" + assert JavaScriptFunctionsParser._is_position_inside_string_literal(line, 12) + + def test_inside_backtick(self): + line = "let x = `hello world`;" + assert JavaScriptFunctionsParser._is_position_inside_string_literal(line, 12) + + def test_after_string(self): + line = 'let x = "hi"; let y = 1;' + # Position after the closing quote + assert not JavaScriptFunctionsParser._is_position_inside_string_literal(line, 16) + + +# ============================================================================= +# is_root_package +# ============================================================================= + +class TestIsRootPackageCoverage: + """Test root package detection.""" + + def test_root_package(self, parser): + doc = Document(page_content="function foo() {}", metadata={"source": "src/app.js"}) + assert parser.is_root_package(doc) + + def test_node_modules_package(self, parser): + doc = Document(page_content="function foo() {}", metadata={"source": "node_modules/lodash/index.js"}) + assert not parser.is_root_package(doc) + + def test_empty_source(self, parser): + doc = Document(page_content="function foo() {}", metadata={}) + assert parser.is_root_package(doc) + + +# ============================================================================= +# is_searchable_file_name +# ============================================================================= + +class TestIsSearchableFileNameCoverage: + """Test file name search exclusion.""" + + def test_regular_file(self, parser): + doc = Document(page_content="", metadata={"source": "src/utils.js"}) + assert parser.is_searchable_file_name(doc) + + def test_test_file_excluded(self, parser): + doc = Document(page_content="", metadata={"source": "src/utils.test.js"}) + assert not parser.is_searchable_file_name(doc) + + def test_spec_file_excluded(self, parser): + doc = Document(page_content="", metadata={"source": "src/utils.spec.js"}) + assert not parser.is_searchable_file_name(doc) + + def test_tests_dir_excluded(self, parser): + doc = Document(page_content="", metadata={"source": "__tests__/utils.js"}) + assert not parser.is_searchable_file_name(doc) + + def test_test_dir_excluded(self, parser): + doc = Document(page_content="", metadata={"source": "/test/utils.js"}) + assert not parser.is_searchable_file_name(doc) + + +# ============================================================================= +# is_doc_type +# ============================================================================= + +class TestIsDocTypeCoverage: + """Test class document type detection.""" + + def test_class_is_doc_type(self, parser): + doc = Document(page_content="class MyClass { }", metadata={"content_type": "functions_classes"}) + assert parser.is_doc_type(doc) + + def test_export_class_is_doc_type(self, parser): + doc = Document(page_content="export class MyClass { }", metadata={"content_type": "functions_classes"}) + assert parser.is_doc_type(doc) + + def test_export_default_class_is_doc_type(self, parser): + doc = Document(page_content="export default class MyClass { }", metadata={"content_type": "functions_classes"}) + assert parser.is_doc_type(doc) + + def test_function_not_doc_type(self, parser): + doc = Document(page_content="function foo() {}", metadata={"content_type": "functions_classes"}) + assert not parser.is_doc_type(doc) + + def test_wrong_content_type(self, parser): + doc = Document(page_content="class MyClass { }", metadata={"content_type": "simplified_code"}) + assert not parser.is_doc_type(doc) + + +# ============================================================================= +# is_same_package +# ============================================================================= + +class TestIsSamePackageCoverage: + """Test package name comparison.""" + + def test_same_name(self, parser): + assert parser.is_same_package("lodash", "lodash") + + def test_case_insensitive(self, parser): + assert parser.is_same_package("Lodash", "lodash") + + def test_different_packages(self, parser): + assert not parser.is_same_package("lodash", "express") + + +# ============================================================================= +# trivial accessors +# ============================================================================= + +class TestTrivialAccessorsCoverage: + """Test trivial accessor methods return correct values.""" + + def test_supported_files_extensions(self, parser): + exts = parser.supported_files_extensions() + assert ".js" in exts + assert ".mjs" in exts + assert ".cjs" in exts + + def test_get_function_reserved_word(self, parser): + assert parser.get_function_reserved_word() == "function" + + def test_get_type_reserved_word(self, parser): + assert parser.get_type_reserved_word() == "class" + + def test_get_dummy_function(self, parser): + result = parser.get_dummy_function("myFunc") + assert "function" in result + assert "myFunc" in result + assert "()" in result + + def test_dir_name_for_3rd_party_packages(self, parser): + assert parser.dir_name_for_3rd_party_packages() == "node_modules" + + def test_is_script_language(self): + assert JavaScriptFunctionsParser.is_script_language() is True + + def test_get_constructor_method_name(self): + assert JavaScriptFunctionsParser.get_constructor_method_name() == "constructor" + + def test_get_comment_line_notation(self): + assert JavaScriptFunctionsParser.get_comment_line_notation() == "//" + + def test_get_class_name_from_class_function(self, parser): + doc = Document(page_content="method() { }\n//(class: MyClass)", metadata={"source": "a.js"}) + assert parser.get_class_name_from_class_function(doc) == "MyClass" + + def test_get_class_name_no_annotation(self, parser): + doc = Document(page_content="function standalone() { }", metadata={"source": "a.js"}) + assert parser.get_class_name_from_class_function(doc) is None + + +# ============================================================================= +# search_for_called_function core branching logic +# ============================================================================= + +class TestSearchForCalledFunctionBranching: + """Tests for the three qualified-call branches in search_for_called_function + (lines 278-295): this-equality, local var type resolution, and import matching.""" + + def test_this_call_same_class_returns_true(self, parser): + """When caller uses this.method() and both caller and callee are in the + same class, search_for_called_function should return True.""" + caller = Document( + page_content="save() {\n this.validate();\n}\n//(class: UserService)", + metadata={"source": "src/services/user.js", "content_type": "functions_classes"} + ) + callee = Document( + page_content="validate() {\n return true;\n}\n//(class: UserService)", + metadata={"source": "src/services/user.js", "content_type": "functions_classes"} + ) + code_docs = { + "src/services/user.js": Document( + page_content="class UserService {\n save() { this.validate(); }\n validate() { return true; }\n}", + metadata={"source": "src/services/user.js", "content_type": "simplified_code"} + ) + } + result = parser.search_for_called_function( + caller_function=caller, + callee_function_name="validate", + callee_function=callee, + callee_function_package="root_project", + code_documents=code_docs, + type_documents=[], + callee_function_file_name="src/services/user.js", + fields_of_types={}, + functions_local_variables_index={}, + documents_of_functions=[], + ) + assert result is True + + def test_this_call_different_class_returns_false(self, parser): + """When caller uses this.method() but caller and callee are in different + classes, the this-equality branch should NOT match.""" + caller = Document( + page_content="run() {\n this.execute();\n}\n//(class: TaskRunner)", + metadata={"source": "src/runner.js", "content_type": "functions_classes"} + ) + callee = Document( + page_content="execute() {\n return 1;\n}\n//(class: CommandHandler)", + metadata={"source": "src/handler.js", "content_type": "functions_classes"} + ) + code_docs = { + "src/runner.js": Document( + page_content="class TaskRunner { run() { this.execute(); } }", + metadata={"source": "src/runner.js", "content_type": "simplified_code"} + ) + } + result = parser.search_for_called_function( + caller_function=caller, + callee_function_name="execute", + callee_function=callee, + callee_function_package="root_project", + code_documents=code_docs, + type_documents=[], + callee_function_file_name="src/handler.js", + fields_of_types={}, + functions_local_variables_index={}, + documents_of_functions=[], + ) + assert result is False + + def test_local_var_type_resolution_returns_true(self, parser): + """When a caller has a local variable typed to the callee's class, + a qualified call through that variable should return True.""" + caller = Document( + page_content="function processOrder(db) {\n db.save();\n}", + metadata={"source": "src/app.js", "content_type": "functions_classes"} + ) + callee = Document( + page_content="save() {\n return this.conn.insert();\n}\n//(class: Database)", + metadata={"source": "src/db.js", "content_type": "functions_classes"} + ) + code_docs = { + "src/app.js": Document( + page_content="function processOrder(db) { db.save(); }", + metadata={"source": "src/app.js", "content_type": "simplified_code"} + ) + } + local_vars_index = { + "processOrder@src/app.js": { + "db": {"type": "Database"} + } + } + result = parser.search_for_called_function( + caller_function=caller, + callee_function_name="save", + callee_function=callee, + callee_function_package="root_project", + code_documents=code_docs, + type_documents=[], + callee_function_file_name="src/db.js", + fields_of_types={}, + functions_local_variables_index=local_vars_index, + documents_of_functions=[], + ) + assert result is True + + def test_local_var_wrong_type_does_not_match(self, parser): + """When the local variable's type does not match the callee's class, + the local var branch should not match.""" + caller = Document( + page_content="function handle(svc) {\n svc.save();\n}", + metadata={"source": "src/app.js", "content_type": "functions_classes"} + ) + callee = Document( + page_content="save() {\n return 1;\n}\n//(class: Database)", + metadata={"source": "src/db.js", "content_type": "functions_classes"} + ) + code_docs = { + "src/app.js": Document( + page_content="function handle(svc) { svc.save(); }", + metadata={"source": "src/app.js", "content_type": "simplified_code"} + ) + } + local_vars_index = { + "handle@src/app.js": { + "svc": {"type": "Logger"} + } + } + result = parser.search_for_called_function( + caller_function=caller, + callee_function_name="save", + callee_function=callee, + callee_function_package="root_project", + code_documents=code_docs, + type_documents=[], + callee_function_file_name="src/db.js", + fields_of_types={}, + functions_local_variables_index=local_vars_index, + documents_of_functions=[], + ) + assert result is False + + def test_qualified_call_import_matching_returns_true(self, parser): + """When parts[-1] matches callee_function_name and the qualifier + (identifier) is imported from the callee_function_package, should + return True via the is_package_imported branch.""" + caller = Document( + page_content="function render() {\n Handlebars.compile(tmpl);\n}", + metadata={"source": "src/views/main.js", "content_type": "functions_classes"} + ) + callee = Document( + page_content="compile(template) {\n return parse(template);\n}\n//(class: Handlebars)", + metadata={ + "source": "node_modules/handlebars/dist/cjs/handlebars.js", + "content_type": "functions_classes" + } + ) + code_docs = { + "src/views/main.js": Document( + page_content=( + "import Handlebars from 'handlebars';\n" + "function render() { Handlebars.compile(tmpl); }" + ), + metadata={"source": "src/views/main.js", "content_type": "simplified_code"} + ) + } + result = parser.search_for_called_function( + caller_function=caller, + callee_function_name="compile", + callee_function=callee, + callee_function_package="handlebars", + code_documents=code_docs, + type_documents=[], + callee_function_file_name="node_modules/handlebars/dist/cjs/handlebars.js", + fields_of_types={}, + functions_local_variables_index={}, + documents_of_functions=[], + ) + assert result is True + + +# ============================================================================= +# Tests for search_for_called_function — basic scenarios +# ============================================================================= + +class TestSearchForCalledFunction: + """Basic tests for search_for_called_function covering this-call, + local variable type matching, and no-call scenarios.""" + + def test_this_method_call_same_class(self, parser): + callee_function = Document( + page_content="render() {\n return '
';\n}//(class: Widget)", + metadata={"source": "widget.js", "content_type": "functions_classes"}, + ) + caller_function = Document( + page_content="update() {\n this.render();\n}//(class: Widget)", + metadata={"source": "widget.js", "content_type": "functions_classes"}, + ) + code_documents = { + "widget.js": Document( + page_content=( + "class Widget {\n" + " update() {\n" + " this.render();\n" + " }\n" + " render() {\n" + " return '
';\n" + " }\n" + "}" + ), + metadata={"source": "widget.js"}, + ), + } + + result = parser.search_for_called_function( + caller_function, "render", callee_function, "", code_documents, [], "", {}, {}, [] + ) + + assert result is True + + def test_local_variable_type_match(self, parser): + callee_function = Document( + page_content="log(msg) {\n console.log(msg);\n}//(class: Logger)", + metadata={"source": "logger.js", "content_type": "functions_classes"}, + ) + caller_function = Document( + page_content="function main() {\n const logger = new Logger();\n logger.log('hello');\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + code_documents = { + "app.js": Document( + page_content=( + "const Logger = require('./logger');\n" + "function main() {\n" + " const logger = new Logger();\n" + " logger.log('hello');\n" + "}" + ), + metadata={"source": "app.js"}, + ), + "logger.js": Document( + page_content="log(msg) {\n console.log(msg);\n}//(class: Logger)", + metadata={"source": "logger.js"}, + ), + } + functions_local_variables_index = { + "main@app.js": {"logger": {"type": "Logger"}}, + } + + result = parser.search_for_called_function( + caller_function, "log", callee_function, "logger-pkg", code_documents, + [], "logger.js", {}, functions_local_variables_index, [] + ) + + assert result is True + + def test_callee_not_called(self, parser): + """Caller body does not contain any call to the callee function.""" + callee_function = Document( + page_content="doWork() {\n return 42;\n}//(class: Worker)", + metadata={"source": "worker.js", "content_type": "functions_classes"}, + ) + caller_function = Document( + page_content="function main() {\n console.log('hello');\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + code_documents = { + "app.js": Document( + page_content="function main() {\n console.log('hello');\n}", + metadata={"source": "app.js"}, + ), + } + + result = parser.search_for_called_function( + caller_function, "doWork", callee_function, "worker-pkg", code_documents, + [], "worker.js", {}, {}, [] + ) + + assert result is False + + +# ============================================================================= +# Tests for get_function_name — basic focused scenarios +# ============================================================================= + +class TestGetFunctionNameBasic: + """Focused unit tests for get_function_name covering common patterns.""" + + def test_regular_function_declaration(self, parser): + func = Document( + page_content="function myFunc() { return 1; }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.get_function_name(func) == "myFunc" + + def test_arrow_function_basic(self, parser): + func = Document( + page_content="const add = (a, b) => { return a + b; }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.get_function_name(func) == "add" + + def test_class_method_with_annotation(self, parser): + func = Document( + page_content="getValue() { return this.value; }//(class: MyClass)", + metadata={"content_type": "functions_classes"}, + ) + assert parser.get_function_name(func) == "getValue" + + def test_empty_content_returns_empty_string(self, parser): + """Empty page_content for a valid functions_classes document returns ''.""" + func = Document( + page_content="", + metadata={"content_type": "functions_classes"}, + ) + assert parser.get_function_name(func) == "" + + def test_raises_value_error_for_class_declaration(self, parser): + func = Document( + page_content="class MyClass { constructor() {} }", + metadata={"content_type": "functions_classes"}, + ) + with pytest.raises(ValueError, match="Only function document is supported"): + parser.get_function_name(func) + + def test_async_function_basic(self, parser): + func = Document( + page_content="async function fetchData() { return await fetch('/api'); }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.get_function_name(func) == "fetchData" + + def test_export_default_anonymous_returns_empty(self, parser): + """export default function() should return empty string (anonymous).""" + func = Document( + page_content="export default function() { return 1; }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.get_function_name(func) == "" + + +# ============================================================================= +# Tests for _get_function_calls — basic focused scenarios +# ============================================================================= + +class TestGetFunctionCallsBasic: + """Focused unit tests for _get_function_calls covering common patterns.""" + + def test_direct_call(self, parser): + caller = Document( + page_content="function render() {\n template();\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + result = parser._get_function_calls(caller, "template") + assert "template" in result + + def test_qualified_call_basic(self, parser): + caller = Document( + page_content="function render() {\n lodash.template();\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + result = parser._get_function_calls(caller, "template") + assert "lodash.template" in result + + def test_no_match(self, parser): + caller = Document( + page_content="function render() {\n console.log('hi');\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + result = parser._get_function_calls(caller, "template") + assert result == [] + + def test_empty_callee_name(self, parser): + caller = Document( + page_content="function render() {\n template();\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + result = parser._get_function_calls(caller, "") + assert result == [] + + def test_optional_chaining_call(self, parser): + """obj?.method() should match and strip the '?'.""" + caller = Document( + page_content="function run() {\n obj?.process();\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + result = parser._get_function_calls(caller, "process") + assert "obj.process" in result + + def test_callback_reference(self, parser): + """Function passed as callback (no parens after name) is detected.""" + caller = Document( + page_content="function run() {\n setTimeout(cleanup, 1000);\n}", + metadata={"source": "app.js", "content_type": "functions_classes"}, + ) + result = parser._get_function_calls(caller, "cleanup") + assert "cleanup" in result + + +# ============================================================================= +# Tests for is_comment_line +# ============================================================================= + +class TestIsCommentLine: + """Tests for the is_comment_line method covering single-line comments, + block comments, code lines, and generator method edge cases.""" + + def test_single_line_comment(self, parser): + assert parser.is_comment_line("// this is a comment") is True + + def test_block_comment(self, parser): + assert parser.is_comment_line("/* block comment */") is True + + def test_code_line(self, parser): + assert parser.is_comment_line("code();") is False + + def test_indented_comment(self, parser): + assert parser.is_comment_line(" // indented comment") is True + + def test_empty_line(self, parser): + assert parser.is_comment_line("") is False + + def test_comment_after_code_is_not_comment_line(self, parser): + """A line starting with code is not a comment line even if it has a trailing comment.""" + assert parser.is_comment_line("x = 1; // trailing") is False + + def test_block_comment_continuation(self, parser): + """Block comment continuation line starting with *.""" + assert parser.is_comment_line(" * @param foo") is True + + def test_lone_asterisk(self, parser): + """Lone * is a comment continuation.""" + assert parser.is_comment_line(" *") is True + + def test_block_comment_end(self, parser): + """Block comment end */ is a comment line.""" + assert parser.is_comment_line(" */") is True + + def test_generator_method_not_comment(self, parser): + """Generator method *gen() must not be classified as a comment.""" + assert parser.is_comment_line(" *myGenerator() {") is False + + def test_generator_symbol_iterator_not_comment(self, parser): + """Generator *[Symbol.iterator]() must not be classified as a comment.""" + assert parser.is_comment_line(" *[Symbol.iterator]() {") is False + + def test_generator_with_space_not_comment(self, parser): + """Generator * gen() with space must not be classified as a comment.""" + assert parser.is_comment_line(" * gen() {") is False + + +# ============================================================================= +# Tests for _trace_variable_to_value +# ============================================================================= + +class TestTraceVariableToValue: + """Tests for the _trace_variable_to_value method covering string resolution, + variable chains, not-found cases, comment skipping, and circular references.""" + + def test_string_double_quotes(self, parser): + lines = ['const foo = "hello";'] + assert parser._trace_variable_to_value("foo", lines) == "hello" + + def test_string_single_quotes(self, parser): + lines = ["const bar = 'world';"] + assert parser._trace_variable_to_value("bar", lines) == "world" + + def test_variable_chain(self, parser): + """Tracing a variable assigned from another variable follows the chain.""" + lines = [ + 'const a = "value";', + 'const b = a;', + ] + assert parser._trace_variable_to_value("b", lines) == "value" + + def test_variable_not_found(self, parser): + lines = ['const x = "something";'] + assert parser._trace_variable_to_value("nothere", lines) == "" + + def test_skips_comment_lines(self, parser): + """Comment lines containing a matching assignment are skipped.""" + lines = [ + '// const target = "wrong";', + 'const target = "right";', + ] + assert parser._trace_variable_to_value("target", lines) == "right" + + def test_circular_reference_returns_empty(self, parser): + """Circular variable references terminate without infinite recursion.""" + lines = [ + 'const a = b;', + 'const b = a;', + ] + assert parser._trace_variable_to_value("a", lines) == "" + + +# ============================================================================= +# Tests for is_function — basic focused scenarios +# ============================================================================= + +class TestIsFunctionBasic: + """Focused unit tests for is_function covering function, class, interface, + wrong content_type, exported class, and enum scenarios.""" + + def test_function_content(self, parser): + doc = Document( + page_content="function hello() { return 1; }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.is_function(doc) is True + + def test_class_content(self, parser): + doc = Document( + page_content="class MyClass { constructor() {} }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.is_function(doc) is False + + def test_interface_content(self, parser): + doc = Document( + page_content="interface MyInterface { name: string; }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.is_function(doc) is False + + def test_wrong_content_type_basic(self, parser): + doc = Document( + page_content="function hello() { return 1; }", + metadata={"content_type": "full_source"}, + ) + assert parser.is_function(doc) is False + + def test_exported_class(self, parser): + doc = Document( + page_content="export class Foo extends Bar { }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.is_function(doc) is False + + def test_enum_content(self, parser): + doc = Document( + page_content="enum Direction { Up, Down }", + metadata={"content_type": "functions_classes"}, + ) + assert parser.is_function(doc) is False + + +# ============================================================================= +# Tests for is_package_imported — basic focused scenarios +# ============================================================================= + +class TestIsPackageImportedBasic: + """Focused unit tests for is_package_imported covering ES6 import, require, + no import, empty identifier, and import * as patterns.""" + + def test_es6_import_present(self, parser): + code = "import { template } from 'lodash';\nfunction render() { template(); }" + assert parser.is_package_imported(code, "template", "lodash") is True + + def test_require_import_present(self, parser): + code = "const template = require('lodash');\nfunction render() { template(); }" + assert parser.is_package_imported(code, "template", "lodash") is True + + def test_no_import(self, parser): + code = "function render() { template(); }" + assert parser.is_package_imported(code, "template", "lodash") is False + + def test_empty_identifier(self, parser): + code = "import { template } from 'lodash';" + assert parser.is_package_imported(code, "", "lodash") is False + + def test_import_star_as_named_identifier(self, parser): + """import * as alias from 'pkg' -- the alias is a distinct identifier from the package.""" + code = "import * as _ from 'lodash';\nfunction render() { _.template(); }" + assert parser.is_package_imported(code, "_", "lodash") is True + + +# ============================================================================= +# Tests for get_package_names — basic focused scenarios +# ============================================================================= + +class TestGetPackageNamesBasic: + """Focused unit tests for get_package_names covering root project, + third-party, and scoped package paths.""" + + def test_root_project_path(self, parser): + func = Document( + page_content="function util() {}", + metadata={"source": "src/utils.js", "content_type": "functions_classes"}, + ) + assert parser.get_package_names(func) == ["root_project"] + + def test_third_party_package(self, parser): + func = Document( + page_content="function template() {}", + metadata={"source": "node_modules/lodash/index.js", "content_type": "functions_classes"}, + ) + assert parser.get_package_names(func) == ["lodash"] + + def test_scoped_package(self, parser): + func = Document( + page_content="function transform() {}", + metadata={"source": "node_modules/@babel/core/index.js", "content_type": "functions_classes"}, + ) + assert parser.get_package_names(func) == ["@babel/core"]