Files
2026-07-13 12:49:17 +08:00

723 lines
30 KiB
Python

# Copyright (c) 2025 ByteDance Ltd. and/or its affiliates
# SPDX-License-Identifier: MIT
import hashlib
import json
import sqlite3
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Literal
from tree_sitter import Node, Parser
from tree_sitter_languages import get_parser
from trae_agent.tools.ckg.base import ClassEntry, FunctionEntry, extension_to_language
from trae_agent.utils.constants import LOCAL_STORAGE_PATH
CKG_DATABASE_PATH = LOCAL_STORAGE_PATH / "ckg"
CKG_STORAGE_INFO_FILE = CKG_DATABASE_PATH / "storage_info.json"
CKG_DATABASE_EXPIRY_TIME = 60 * 60 * 24 * 7 # 1 week in seconds
"""
Known issues:
1. When a subdirectory of a codebase that has already been indexed, the CKG is built again for this subdirectory.
2. The rebuilding logic can be improved by only rebuilding for files that have been modified.
3. For JavaScript and TypeScript, the AST is not complete: anonymous functions, arrow functions, etc., are not parsed.
"""
def get_ckg_database_path(codebase_snapshot_hash: str) -> Path:
"""Get the path to the CKG database for a codebase path."""
return CKG_DATABASE_PATH / f"{codebase_snapshot_hash}.db"
def is_git_repository(folder_path: Path) -> bool:
"""Check if the folder is a git repository."""
try:
result = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=folder_path,
capture_output=True,
text=True,
timeout=5,
)
return result.returncode == 0 and result.stdout.strip() == "true"
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return False
def get_git_status_hash(folder_path: Path) -> str:
"""Get hash for git repository (clean or dirty)."""
try:
# Check if we have any uncommitted changes
status_result = subprocess.run(
["git", "status", "--porcelain"],
cwd=folder_path,
capture_output=True,
text=True,
timeout=10,
)
# Get the current commit hash
commit_result = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=folder_path, capture_output=True, text=True, timeout=5
)
base_hash = commit_result.stdout.strip()
# If no uncommitted changes, just use the commit hash
if not status_result.stdout.strip():
return f"git-clean-{base_hash}"
# If there are uncommitted changes, include them in the hash
uncommitted_hash = hashlib.md5(status_result.stdout.encode()).hexdigest()[:8]
return f"git-dirty-{base_hash}-{uncommitted_hash}"
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
# Fallback to file metadata hash if git commands fail
return get_file_metadata_hash(folder_path)
def get_file_metadata_hash(folder_path: Path) -> str:
"""Get hash based on file metadata (name, mtime, size) for non-git repositories."""
hash_md5 = hashlib.md5()
for file in folder_path.glob("**/*"):
if file.is_file() and not file.name.startswith("."):
stat = file.stat()
hash_md5.update(file.name.encode())
hash_md5.update(str(stat.st_mtime).encode()) # modification time
hash_md5.update(str(stat.st_size).encode()) # file size
return f"metadata-{hash_md5.hexdigest()}"
def get_folder_snapshot_hash(folder_path: Path) -> str:
"""Get the hash of the folder snapshot, to make sure that the CKG is up to date."""
# Strategy 1: Git repository
if is_git_repository(folder_path):
return get_git_status_hash(folder_path)
# Strategy 2: Non-git repository - file metadata
return get_file_metadata_hash(folder_path)
def clear_older_ckg():
"""Iterate over all the files in the CKG storage directory and delete the ones that are older than 1 week."""
for file in CKG_DATABASE_PATH.glob("**/*"):
if (
file.is_file()
and not file.name.startswith(".")
and file.name.endswith(".db")
and file.stat().st_mtime < datetime.now().timestamp() - CKG_DATABASE_EXPIRY_TIME
):
try:
file.unlink()
except Exception as e:
print(f"error deleting older CKG database - {file.absolute().as_posix()}: {e}")
SQL_LIST = {
"functions": """
CREATE TABLE IF NOT EXISTS functions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
body TEXT NOT NULL,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
parent_function TEXT,
parent_class TEXT
)""",
"classes": """
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
body TEXT NOT NULL,
fields TEXT,
methods TEXT,
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL
)""",
}
class CKGDatabase:
def __init__(self, codebase_path: Path):
self._db_connection: sqlite3.Connection
self._codebase_path: Path = codebase_path
if not CKG_DATABASE_PATH.exists():
CKG_DATABASE_PATH.mkdir(parents=True, exist_ok=True)
ckg_storage_info: dict[str, str] = {}
# to save time and storage, we try to reuse the existing database if the codebase snapshot hash is the same
# get the existing codebase snapshot hash from the storage info file
if CKG_STORAGE_INFO_FILE.exists():
with open(CKG_STORAGE_INFO_FILE, "r") as f:
ckg_storage_info = json.load(f)
if codebase_path.absolute().as_posix() in ckg_storage_info:
existing_codebase_snapshot_hash = ckg_storage_info[
codebase_path.absolute().as_posix()
]
else:
existing_codebase_snapshot_hash = ""
else:
existing_codebase_snapshot_hash = ""
current_codebase_snapshot_hash = get_folder_snapshot_hash(codebase_path)
if existing_codebase_snapshot_hash == current_codebase_snapshot_hash:
# we can reuse the existing database
database_path = get_ckg_database_path(existing_codebase_snapshot_hash)
else:
# we need to create a new database and delete the old one
database_path = get_ckg_database_path(existing_codebase_snapshot_hash)
if database_path.exists():
database_path.unlink()
database_path = get_ckg_database_path(current_codebase_snapshot_hash)
ckg_storage_info[codebase_path.absolute().as_posix()] = current_codebase_snapshot_hash
with open(CKG_STORAGE_INFO_FILE, "w") as f:
json.dump(ckg_storage_info, f)
if database_path.exists():
# reuse existing database
self._db_connection = sqlite3.connect(database_path)
else:
# create new database with tables and build the CKG
self._db_connection = sqlite3.connect(database_path)
for sql in SQL_LIST.values():
self._db_connection.execute(sql)
self._db_connection.commit()
self._construct_ckg()
def __del__(self):
self._db_connection.close()
def update(self):
"""Update the CKG database."""
self._construct_ckg()
def _recursive_visit_python(
self,
root_node: Node,
file_path: str,
parent_class: ClassEntry | None = None,
parent_function: FunctionEntry | None = None,
):
"""Recursively visit the Python AST and insert the entries into the database."""
if root_node.type == "function_definition":
function_name_node = root_node.child_by_field_name("name")
if function_name_node:
function_entry = FunctionEntry(
name=function_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
if parent_function and parent_class:
# determine if the function is a method of the class or a function within a function
if (
parent_function.start_line >= parent_class.start_line
and parent_function.end_line <= parent_class.end_line
):
function_entry.parent_function = parent_function.name
else:
function_entry.parent_class = parent_class.name
elif parent_function:
function_entry.parent_function = parent_function.name
elif parent_class:
function_entry.parent_class = parent_class.name
self._insert_entry(function_entry)
parent_function = function_entry
elif root_node.type == "class_definition":
class_name_node = root_node.child_by_field_name("name")
if class_name_node:
class_body_node = root_node.child_by_field_name("body")
class_methods = ""
class_entry = ClassEntry(
name=class_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
if class_body_node:
for child in class_body_node.children:
function_definition_node = None
if child.type == "decorated_definition":
function_definition_node = child.child_by_field_name("definition")
elif child.type == "function_definition":
function_definition_node = child
if function_definition_node:
method_name_node = function_definition_node.child_by_field_name("name")
if method_name_node:
parameters_node = function_definition_node.child_by_field_name(
"parameters"
)
return_type_node = child.child_by_field_name("return_type")
class_method_info = method_name_node.text.decode()
if parameters_node:
class_method_info += f"{parameters_node.text.decode()}"
if return_type_node:
class_method_info += f" -> {return_type_node.text.decode()}"
class_methods += f"- {class_method_info}\n"
class_entry.methods = class_methods.strip() if class_methods != "" else None
parent_class = class_entry
self._insert_entry(class_entry)
if len(root_node.children) != 0:
for child in root_node.children:
self._recursive_visit_python(child, file_path, parent_class, parent_function)
def _recursive_visit_java(
self,
root_node: Node,
file_path: str,
parent_class: ClassEntry | None = None,
parent_function: FunctionEntry | None = None,
):
"""Recursively visit the Java AST and insert the entries into the database."""
if root_node.type == "class_declaration":
class_name_node = root_node.child_by_field_name("name")
if class_name_node:
class_entry = ClassEntry(
name=class_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
class_body_node = root_node.child_by_field_name("body")
class_methods = ""
class_fields = ""
if class_body_node:
for child in class_body_node.children:
if child.type == "field_declaration":
class_fields += f"- {child.text.decode()}\n"
if child.type == "method_declaration":
method_builder = ""
for method_property in child.children:
if method_property.type == "block":
break
method_builder += f"{method_property.text.decode()} "
method_builder = method_builder.strip()
class_methods += f"- {method_builder}\n"
class_entry.methods = class_methods.strip() if class_methods != "" else None
class_entry.fields = class_fields.strip() if class_fields != "" else None
parent_class = class_entry
self._insert_entry(class_entry)
elif root_node.type == "method_declaration":
method_name_node = root_node.child_by_field_name("name")
if method_name_node:
method_entry = FunctionEntry(
name=method_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
if parent_class:
method_entry.parent_class = parent_class.name
self._insert_entry(method_entry)
if len(root_node.children) != 0:
for child in root_node.children:
self._recursive_visit_java(child, file_path, parent_class, parent_function)
def _recursive_visit_cpp(
self,
root_node: Node,
file_path: str,
parent_class: ClassEntry | None = None,
parent_function: FunctionEntry | None = None,
):
"""Recursively visit the C++ AST and insert the entries into the database."""
if root_node.type == "class_specifier":
class_name_node = root_node.child_by_field_name("name")
if class_name_node:
class_entry = ClassEntry(
name=class_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
class_body_node = root_node.child_by_field_name("body")
class_methods = ""
class_fields = ""
if class_body_node:
for child in class_body_node.children:
if child.type == "function_definition":
method_builder = ""
for method_property in child.children:
if method_property.type == "compound_statement":
break
method_builder += f"{method_property.text.decode()} "
method_builder = method_builder.strip()
class_methods += f"- {method_builder}\n"
if child.type == "field_declaration":
child_is_property = True
for child_property in child.children:
if child_property.type == "function_declarator":
child_is_property = False
break
if child_is_property:
class_fields += f"- {child.text.decode()}\n"
else:
class_methods += f"- {child.text.decode()}\n"
class_entry.methods = class_methods.strip() if class_methods != "" else None
class_entry.fields = class_fields.strip() if class_fields != "" else None
parent_class = class_entry
self._insert_entry(class_entry)
elif root_node.type == "function_definition":
function_declarator_node = root_node.child_by_field_name("declarator")
if function_declarator_node:
function_name_node = function_declarator_node.child_by_field_name("declarator")
if function_name_node:
function_entry = FunctionEntry(
name=function_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
if parent_class:
function_entry.parent_class = parent_class.name
self._insert_entry(function_entry)
if len(root_node.children) != 0:
for child in root_node.children:
self._recursive_visit_cpp(child, file_path, parent_class, parent_function)
def _recursive_visit_c(
self,
root_node: Node,
file_path: str,
parent_class: ClassEntry | None = None,
parent_function: FunctionEntry | None = None,
):
"""Recursively visit the C AST and insert the entries into the database."""
if root_node.type == "function_definition":
function_declarator_node = root_node.child_by_field_name("declarator")
if function_declarator_node:
function_name_node = function_declarator_node.child_by_field_name("declarator")
if function_name_node:
function_entry = FunctionEntry(
name=function_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
self._insert_entry(function_entry)
if len(root_node.children) != 0:
for child in root_node.children:
self._recursive_visit_c(child, file_path, parent_class, parent_function)
def _recursive_visit_typescript(
self,
root_node: Node,
file_path: str,
parent_class: ClassEntry | None = None,
parent_function: FunctionEntry | None = None,
):
if root_node.type == "class_declaration":
class_name_node = root_node.child_by_field_name("name")
if class_name_node:
class_entry = ClassEntry(
name=class_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
methods = ""
fields = ""
class_body_node = root_node.child_by_field_name("body")
if class_body_node:
for child in class_body_node.children:
if child.type == "method_definition":
method_builder = ""
for method_property in child.children:
if method_property.type == "statement_block":
break
method_builder += f"{method_property.text.decode()} "
method_builder = method_builder.strip()
methods += f"- {method_builder}\n"
elif child.type == "public_field_definition":
fields += f"- {child.text.decode()}\n"
class_entry.methods = methods.strip() if methods != "" else None
class_entry.fields = fields.strip() if fields != "" else None
parent_class = class_entry
self._insert_entry(class_entry)
elif root_node.type == "method_definition":
method_name_node = root_node.child_by_field_name("name")
if method_name_node:
method_entry = FunctionEntry(
name=method_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
if parent_class:
method_entry.parent_class = parent_class.name
self._insert_entry(method_entry)
if len(root_node.children) != 0:
for child in root_node.children:
self._recursive_visit_typescript(child, file_path, parent_class, parent_function)
def _recursive_visit_javascript(
self,
root_node: Node,
file_path: str,
parent_class: ClassEntry | None = None,
parent_function: FunctionEntry | None = None,
):
"""Recursively visit the JavaScript AST and insert the entries into the database."""
if root_node.type == "class_declaration":
class_name_node = root_node.child_by_field_name("name")
if class_name_node:
class_entry = ClassEntry(
name=class_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
methods = ""
fields = ""
class_body_node = root_node.child_by_field_name("body")
if class_body_node:
for child in class_body_node.children:
if child.type == "method_definition":
method_builder = ""
for method_property in child.children:
if method_property.type == "statement_block":
break
method_builder += f"{method_property.text.decode()} "
method_builder = method_builder.strip()
methods += f"- {method_builder}\n"
elif child.type == "public_field_definition":
fields += f"- {child.text.decode()}\n"
class_entry.methods = methods.strip() if methods != "" else None
class_entry.fields = fields.strip() if fields != "" else None
parent_class = class_entry
self._insert_entry(class_entry)
elif root_node.type == "method_definition":
method_name_node = root_node.child_by_field_name("name")
if method_name_node:
method_entry = FunctionEntry(
name=method_name_node.text.decode(),
file_path=file_path,
body=root_node.text.decode(),
start_line=root_node.start_point[0] + 1,
end_line=root_node.end_point[0] + 1,
)
if parent_class:
method_entry.parent_class = parent_class.name
self._insert_entry(method_entry)
if len(root_node.children) != 0:
for child in root_node.children:
self._recursive_visit_javascript(child, file_path, parent_class, parent_function)
def _construct_ckg(self) -> None:
"""Initialise the code knowledge graph."""
# lazy load the parsers for the languages when needed
language_to_parser: dict[str, Parser] = {}
for file in self._codebase_path.glob("**/*"):
# skip hidden files and files in a hidden directory
if (
file.is_file()
and not file.name.startswith(".")
and "/." not in file.absolute().as_posix()
):
extension = file.suffix
# ignore files with unknown extensions
if extension not in extension_to_language:
continue
language = extension_to_language[extension]
language_parser = language_to_parser.get(language)
if not language_parser:
language_parser = get_parser(language)
language_to_parser[language] = language_parser
tree = language_parser.parse(file.read_bytes())
root_node = tree.root_node
match language:
case "python":
self._recursive_visit_python(root_node, file.absolute().as_posix())
case "java":
self._recursive_visit_java(root_node, file.absolute().as_posix())
case "cpp":
self._recursive_visit_cpp(root_node, file.absolute().as_posix())
case "c":
self._recursive_visit_c(root_node, file.absolute().as_posix())
case "typescript":
self._recursive_visit_typescript(root_node, file.absolute().as_posix())
case "javascript":
self._recursive_visit_javascript(root_node, file.absolute().as_posix())
case _:
continue
def _insert_entry(self, entry: FunctionEntry | ClassEntry) -> None:
"""
Insert entry into db.
Args:
entry: the entry to insert
Returns:
None
"""
# TODO: add try catch block to avoid connection problem.
match entry:
case FunctionEntry():
self._insert_function(entry)
case ClassEntry():
self._insert_class(entry)
self._db_connection.commit()
def _insert_function(self, entry: FunctionEntry) -> None:
"""
Insert function entry including functions and class methodsinto db.
Args:
entry: the entry to insert
Returns:
None
"""
self._db_connection.execute(
"""
INSERT INTO functions (name, file_path, body, start_line, end_line, parent_function, parent_class)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
entry.name,
entry.file_path,
entry.body,
entry.start_line,
entry.end_line,
entry.parent_function,
entry.parent_class,
),
)
def _insert_class(self, entry: ClassEntry) -> None:
"""
Insert class entry into db.
Args:
entry: the entry to insert
Returns:
None
"""
self._db_connection.execute(
"""
INSERT INTO classes (name, file_path, body, fields, methods, start_line, end_line)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
entry.name,
entry.file_path,
entry.body,
entry.fields,
entry.methods,
entry.start_line,
entry.end_line,
),
)
def query_function(
self, identifier: str, entry_type: Literal["function", "class_method"] = "function"
) -> list[FunctionEntry]:
"""
Search for a function in the database.
Args:
identifier: the identifier of the function to search for
Returns:
a list of function entries
"""
records = self._db_connection.execute(
"""SELECT name, file_path, body, start_line, end_line, parent_function, parent_class FROM functions WHERE name = ?""",
(identifier,),
).fetchall()
function_entries: list[FunctionEntry] = []
for record in records:
match entry_type:
case "function":
if record[6] is None:
function_entries.append(
FunctionEntry(
name=record[0],
file_path=record[1],
body=record[2],
start_line=record[3],
end_line=record[4],
parent_function=record[5],
parent_class=record[6],
)
)
case "class_method":
if record[6] is not None:
function_entries.append(
FunctionEntry(
name=record[0],
file_path=record[1],
body=record[2],
start_line=record[3],
end_line=record[4],
parent_function=record[5],
parent_class=record[6],
)
)
return function_entries
def query_class(self, identifier: str) -> list[ClassEntry]:
"""
Search for a class in the database.
Args:
identifier: the identifier of the class to search for
Returns:
a list of class entries
"""
records = self._db_connection.execute(
"""SELECT name, file_path, body, fields, methods, start_line, end_line FROM classes WHERE name = ?""",
(identifier,),
).fetchall()
class_entries: list[ClassEntry] = []
for record in records:
class_entries.append(
ClassEntry(
name=record[0],
file_path=record[1],
body=record[2],
fields=record[3],
methods=record[4],
start_line=record[5],
end_line=record[6],
)
)
return class_entries