Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 20 additions & 64 deletions api/analyzers/javascript/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
from pathlib import Path
from typing import Optional

from multilspy import SyncLanguageServer
from ...entities.entity import Entity
from ...entities.file import File
from ..analyzer import AbstractAnalyzer
from ..tree_sitter_base import TreeSitterAnalyzer

import tree_sitter_javascript as tsjs
from tree_sitter import Language, Node
Expand All @@ -15,13 +13,28 @@
logger = logging.getLogger('code_graph')


class JavaScriptAnalyzer(AbstractAnalyzer):
class JavaScriptAnalyzer(TreeSitterAnalyzer):
"""Analyzer for JavaScript source files using tree-sitter.

Extracts functions, classes, and methods from JavaScript code.
Resolves class inheritance (extends) and function/method call references.
"""

entity_node_types = {
'function_declaration': "Function",
'class_declaration': "Class",
'method_definition': "Method",
}
type_definition_node_types = ('class_declaration',)
callable_definition_node_types = (
'function_declaration',
'method_definition',
'class_declaration',
)
callable_exclude_node_types = ('class_declaration',)
type_resolution_keys = ("base_class",)
method_resolution_keys = ("call",)

def __init__(self) -> None:
"""Initialize the JavaScript analyzer with the tree-sitter JS grammar."""
super().__init__(Language(tsjs.language()))
Expand All @@ -33,26 +46,6 @@ def add_dependencies(self, path: Path, files: list[Path]) -> None:
"""
pass

def get_entity_label(self, node: Node) -> str:
"""Return the graph label for a given AST node type.

Args:
node: A tree-sitter AST node representing a JavaScript entity.

Returns:
One of 'Function', 'Class', or 'Method'.

Raises:
ValueError: If the node type is not a recognised entity.
"""
if node.type == 'function_declaration':
return "Function"
elif node.type == 'class_declaration':
return "Class"
elif node.type == 'method_definition':
return "Method"
raise ValueError(f"Unknown entity type: {node.type}")

def get_entity_name(self, node: Node) -> str:
"""Extract the declared name from a JavaScript entity node.

Expand Down Expand Up @@ -92,10 +85,6 @@ def get_entity_docstring(self, node: Node) -> Optional[str]:
return None
raise ValueError(f"Unknown entity type: {node.type}")

def get_entity_types(self) -> list[str]:
"""Return the tree-sitter node types recognised as JavaScript entities."""
return ['function_declaration', 'class_declaration', 'method_definition']

def add_symbols(self, entity: Entity) -> None:
"""Extract symbols (references) from a JavaScript entity.

Expand Down Expand Up @@ -128,45 +117,12 @@ def is_dependency(self, file_path: str) -> bool:
"""
return "node_modules" in Path(file_path).parts

def resolve_path(self, file_path: str, path: Path) -> str:
"""Resolve an import path relative to the project root."""
return file_path

def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
"""Resolve a type reference to its class declaration entity."""
res = []
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_declaration'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
return res

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
"""Resolve a call expression to the target function or method entity."""
res = []
def _extract_call_target(self, node: Node) -> Optional[Node]:
"""Extract the callable target from a JavaScript call expression."""
if node.type == 'call_expression':
func_node = node.child_by_field_name('function')
if func_node and func_node.type == 'member_expression':
func_node = func_node.child_by_field_name('property')
if func_node:
node = func_node
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
method_dec = self.find_parent(resolved_node, ['function_declaration', 'method_definition', 'class_declaration'])
if method_dec and method_dec.type == 'class_declaration':
continue
if method_dec in file.entities:
res.append(file.entities[method_dec])
return res

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
"""Dispatch symbol resolution based on the symbol category.

Routes ``base_class`` symbols to type resolution and ``call`` symbols
to method resolution.
"""
if key == "base_class":
return self.resolve_type(files, lsp, file_path, path, symbol)
elif key == "call":
return self.resolve_method(files, lsp, file_path, path, symbol)
else:
raise ValueError(f"Unknown key {key}")
return node
61 changes: 29 additions & 32 deletions api/analyzers/kotlin/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from ...entities.entity import Entity
from ...entities.file import File
from typing import Optional
from ..analyzer import AbstractAnalyzer
from ..tree_sitter_base import TreeSitterAnalyzer

from multilspy import SyncLanguageServer

Expand All @@ -12,7 +12,27 @@
import logging
logger = logging.getLogger('code_graph')

class KotlinAnalyzer(AbstractAnalyzer):
class KotlinAnalyzer(TreeSitterAnalyzer):
entity_node_types = {
'class_declaration': "Class",
'object_declaration': "Object",
'function_declaration': "Function",
}
type_definition_node_types = ('class_declaration', 'object_declaration')
callable_definition_node_types = (
'function_declaration',
'class_declaration',
'object_declaration',
)
callable_exclude_node_types = ('class_declaration', 'object_declaration')
type_resolution_keys = (
"implement_interface",
"base_class",
"parameters",
"return_type",
)
method_resolution_keys = ("call",)

def __init__(self) -> None:
super().__init__(Language(tskotlin.language()))

Expand Down Expand Up @@ -44,7 +64,7 @@ def get_entity_name(self, node: Node) -> str:
if child.type == 'identifier':
return child.text.decode('utf-8')
raise ValueError(f"Cannot extract name from entity type: {node.type}")

def get_entity_docstring(self, node: Node) -> Optional[str]:
if node.type in ['class_declaration', 'object_declaration', 'function_declaration']:
# Check for KDoc comment (/** ... */) before the node
Expand All @@ -54,14 +74,11 @@ def get_entity_docstring(self, node: Node) -> Optional[str]:
if comment_text.startswith('/**'):
return comment_text
return None
raise ValueError(f"Unknown entity type: {node.type}")
raise ValueError(f"Unknown entity type: {node.type}")

def get_entity_types(self) -> list[str]:
return ['class_declaration', 'object_declaration', 'function_declaration']

def _get_delegation_types(self, entity: Entity) -> list[tuple]:
"""Extract type identifiers from delegation specifiers in order.

Returns list of (node, is_constructor_invocation) tuples.
constructor_invocation indicates a superclass; plain user_type indicates an interface.
"""
Expand Down Expand Up @@ -91,25 +108,25 @@ def add_symbols(self, entity: Entity) -> None:
entity.add_symbol("base_class", node)
else:
entity.add_symbol("implement_interface", node)

elif entity.node.type == 'object_declaration':
types = self._get_delegation_types(entity)
for node, _ in types:
entity.add_symbol("implement_interface", node)
Comment on lines 112 to 115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve superclass vs interface symbols for object declarations.

_get_delegation_types() already tells you whether a delegated type came from a constructor_invocation, but this branch drops that bit and records every entry as implement_interface. That turns object Foo : Base() into an interface relation instead of a base-class relation, which changes graph semantics in this “non-functional” refactor.

Suggested fix
         elif entity.node.type == 'object_declaration':
             types = self._get_delegation_types(entity)
-            for node, _ in types:
-                entity.add_symbol("implement_interface", node)
+            for node, is_class in types:
+                if is_class:
+                    entity.add_symbol("base_class", node)
+                else:
+                    entity.add_symbol("implement_interface", node)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@api/analyzers/kotlin/analyzer.py` around lines 112 - 115, The current
object_declaration branch discards the kind info from _get_delegation_types and
always calls entity.add_symbol("implement_interface"), turning delegated
base-class relations into interfaces; change the loop to unpack the second value
returned by _get_delegation_types (e.g., for node, kind in types) and branch on
that kind: when kind indicates a constructor_invocation (the superclass case)
call entity.add_symbol using the superclass relation symbol (e.g.,
"extend_class" or the project’s existing superclass symbol), otherwise call
entity.add_symbol("implement_interface") to preserve interface relations.


elif entity.node.type == 'function_declaration':
# Find function calls
captures = self._captures("(call_expression) @reference.call", entity.node)
if 'reference.call' in captures:
for caller in captures['reference.call']:
entity.add_symbol("call", caller)

# Find parameters with types
captures = self._captures("(parameter (user_type (identifier) @parameter))", entity.node)
if 'parameter' in captures:
for parameter in captures['parameter']:
entity.add_symbol("parameters", parameter)

# Find return type
captures = self._captures("(function_declaration (user_type (identifier) @return_type))", entity.node)
if 'return_type' in captures:
Expand All @@ -120,18 +137,6 @@ def is_dependency(self, file_path: str) -> bool:
# Check if file is in a dependency directory (e.g., build, .gradle cache)
return "build/" in file_path or ".gradle/" in file_path or "/cache/" in file_path

def resolve_path(self, file_path: str, path: Path) -> str:
# For Kotlin, just return the file path as-is for now
return file_path

def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_declaration', 'object_declaration'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
return res

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
# For call expressions, we need to extract the function name
Expand All @@ -147,11 +152,3 @@ def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_
res.append(file.entities[method_dec])
break
return res

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
if key in ["implement_interface", "base_class", "parameters", "return_type"]:
return self.resolve_type(files, lsp, file_path, path, symbol)
elif key in ["call"]:
return self.resolve_method(files, lsp, file_path, path, symbol)
else:
raise ValueError(f"Unknown key {key}")
70 changes: 23 additions & 47 deletions api/analyzers/python/analyzer.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,32 @@
import os
import subprocess
from multilspy import SyncLanguageServer
from pathlib import Path

import tomllib
from ...entities import *
from typing import Optional
from ..analyzer import AbstractAnalyzer

from ...entities.entity import Entity
from ..tree_sitter_base import TreeSitterAnalyzer

import tree_sitter_python as tspython
from tree_sitter import Language, Node

import logging
logger = logging.getLogger('code_graph')

class PythonAnalyzer(AbstractAnalyzer):
class PythonAnalyzer(TreeSitterAnalyzer):
entity_node_types = {
'class_definition': "Class",
'function_definition': "Function",
}
type_definition_node_types = ('class_definition',)
callable_definition_node_types = ('function_definition', 'class_definition')
type_resolution_keys = ("base_class", "parameters", "return_type")
method_resolution_keys = ("call",)

def __init__(self) -> None:
super().__init__(Language(tspython.language()))

def add_dependencies(self, path: Path, files: list[Path]):
if Path(f"{path}/venv").is_dir():
return
Expand All @@ -40,30 +49,20 @@ def add_dependencies(self, path: Path, files: list[Path]):
for requirement in requirements:
files.extend(Path(f"{path}/venv/lib/").rglob(f"**/site-packages/{requirement}/*.py"))

def get_entity_label(self, node: Node) -> str:
if node.type == 'class_definition':
return "Class"
elif node.type == 'function_definition':
return "Function"
raise ValueError(f"Unknown entity type: {node.type}")

def get_entity_name(self, node: Node) -> str:
if node.type in ['class_definition', 'function_definition']:
return node.child_by_field_name('name').text.decode('utf-8')
raise ValueError(f"Unknown entity type: {node.type}")

def get_entity_docstring(self, node: Node) -> Optional[str]:
if node.type in ['class_definition', 'function_definition']:
body = node.child_by_field_name('body')
if body.child_count > 0 and body.children[0].type == 'expression_statement':
docstring_node = body.children[0].child(0)
return docstring_node.text.decode('utf-8')
return None
raise ValueError(f"Unknown entity type: {node.type}")

def get_entity_types(self) -> list[str]:
return ['class_definition', 'function_definition']

raise ValueError(f"Unknown entity type: {node.type}")

def add_symbols(self, entity: Entity) -> None:
if entity.node.type == 'class_definition':
superclasses = entity.node.child_by_field_name("superclasses")
Expand All @@ -88,37 +87,14 @@ def add_symbols(self, entity: Entity) -> None:
def is_dependency(self, file_path: str) -> bool:
return "venv" in file_path

def resolve_path(self, file_path: str, path: Path) -> str:
return file_path

def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path, node: Node) -> list[Entity]:
res = []
def _extract_type_target(self, node: Node) -> Optional[Node]:
if node.type == 'attribute':
node = node.child_by_field_name('attribute')
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
type_dec = self.find_parent(resolved_node, ['class_definition'])
if type_dec in file.entities:
res.append(file.entities[type_dec])
return res
return node.child_by_field_name('attribute')
return node

def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]:
res = []
def _extract_call_target(self, node: Node) -> Optional[Node]:
if node.type == 'call':
node = node.child_by_field_name('function')
if node.type == 'attribute':
if node and node.type == 'attribute':
node = node.child_by_field_name('attribute')
for file, resolved_node in self.resolve(files, lsp, file_path, path, node):
method_dec = self.find_parent(resolved_node, ['function_definition', 'class_definition'])
if not method_dec:
continue
if method_dec in file.entities:
res.append(file.entities[method_dec])
return res

def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]:
if key in ["base_class", "parameters", "return_type"]:
return self.resolve_type(files, lsp, file_path, path, symbol)
elif key in ["call"]:
return self.resolve_method(files, lsp, file_path, path, symbol)
else:
raise ValueError(f"Unknown key {key}")
return node
Loading
Loading