diff --git a/src/codegraphcontext/core/cgc_bundle.py b/src/codegraphcontext/core/cgc_bundle.py index 6a0bdd2f..caede2f8 100644 --- a/src/codegraphcontext/core/cgc_bundle.py +++ b/src/codegraphcontext/core/cgc_bundle.py @@ -414,7 +414,11 @@ def _extract_metadata(self, repo_path: Optional[Path]) -> Dict[str, Any]: if branch: metadata["branch"] = branch - try: + # Languages are derived for every bundle, not only repo-scoped ones. + # This used to sit inside the `if repo_path` branch above, so a + # whole-graph export never set the key at all. + try: + if repo_path: repo_str = repo_path.resolve().as_posix() result = session.run(""" MATCH (f:File) @@ -422,11 +426,18 @@ def _extract_metadata(self, repo_path: Optional[Path]) -> Dict[str, Any]: RETURN f.language as language, count(*) as count ORDER BY count DESC """, repo_path=repo_str, repo_prefix=repo_str + "/") - languages = {record["language"]: record["count"] for record in result if record["language"]} - metadata["languages"] = list(languages.keys()) - except Exception: - pass - + else: + result = session.run(""" + MATCH (f:File) + RETURN f.language as language, count(*) as count + ORDER BY count DESC + """) + languages = {record["language"]: record["count"] for record in result if record["language"]} + metadata["languages"] = list(languages.keys()) + except Exception: + metadata.setdefault("languages", []) + + return metadata def _extract_schema(self) -> Dict[str, Any]: diff --git a/src/codegraphcontext/core/database_embedded_kuzu.py b/src/codegraphcontext/core/database_embedded_kuzu.py index 5025dbad..86f026dd 100644 --- a/src/codegraphcontext/core/database_embedded_kuzu.py +++ b/src/codegraphcontext/core/database_embedded_kuzu.py @@ -170,7 +170,7 @@ def _initialize_schema(self): node_tables = [ ("Repository", "path STRING, name STRING, is_dependency BOOLEAN, indexed_at STRING, commit_hash STRING, PRIMARY KEY (path)"), - ("File", "path STRING, name STRING, relative_path STRING, package_name STRING, is_dependency BOOLEAN, PRIMARY KEY (path)"), + ("File", "path STRING, name STRING, relative_path STRING, package_name STRING, language STRING, is_dependency BOOLEAN, PRIMARY KEY (path)"), ("Directory", "path STRING, name STRING, PRIMARY KEY (path)"), ("Module", "name STRING, lang STRING, full_import_name STRING, path STRING, line_number INT64, PRIMARY KEY (name)"), # For types with composite keys (name, path, line_number), we use a 'uid' @@ -325,6 +325,7 @@ def _run_schema_migrations(self): # Simple (non-group) table migrations simple_migrations = [ ("File", "package_name", "STRING"), + ("File", "language", "STRING"), ("Module", "full_import_name", "STRING"), ("Module", "path", "STRING"), ("Module", "line_number", "INT64"), @@ -817,7 +818,7 @@ def _translate_query(self, query: str, parameters: Dict[str, Any]) -> Tuple[str, # 0. Define Schema Map (Strict property filtering) SCHEMA_MAP = { 'Repository': {'path', 'name', 'is_dependency', 'indexed_at', 'commit_hash'}, - 'File': {'path', 'name', 'relative_path', 'package_name', 'is_dependency'}, + 'File': {'path', 'name', 'relative_path', 'package_name', 'language', 'is_dependency'}, 'Directory': {'path', 'name'}, 'Module': {'name', 'lang', 'full_import_name', 'path', 'line_number'}, 'Function': {'uid', 'name', 'path', 'line_number', 'end_line', 'source', 'docstring', 'lang', 'cyclomatic_complexity', 'context', 'context_type', 'class_context', 'class_context_line', 'module_context', 'is_dependency', 'decorators', 'args', 'http_method', 'http_path'}, diff --git a/src/codegraphcontext/tools/indexing/persistence/writer.py b/src/codegraphcontext/tools/indexing/persistence/writer.py index 5d8e9ab1..584dc0e0 100644 --- a/src/codegraphcontext/tools/indexing/persistence/writer.py +++ b/src/codegraphcontext/tools/indexing/persistence/writer.py @@ -243,12 +243,16 @@ def _work(session): session.run( """ MERGE (f:File {path: $path}) - SET f.name = $name, f.relative_path = $relative_path, f.is_dependency = $is_dependency + SET f.name = $name, f.relative_path = $relative_path, f.is_dependency = $is_dependency, + f.language = $language """, path=file_path_str, name=file_name, relative_path=relative_path, is_dependency=is_dependency, + # Bundle export reads f.language to build metadata["languages"]. + # Nothing set it before, so every bundle advertised no languages. + language=lang, ) file_path_obj = Path(file_path_str) diff --git a/tests/unit/core/test_bundle_languages_metadata.py b/tests/unit/core/test_bundle_languages_metadata.py new file mode 100644 index 00000000..f61e10a2 --- /dev/null +++ b/tests/unit/core/test_bundle_languages_metadata.py @@ -0,0 +1,115 @@ +"""Regression tests for bundle metadata["languages"]. + +Bundle export derives the language list from a `language` property on File +nodes. Nothing ever set that property, and the export's `if record["language"]` +guard then filtered every row out -- so every bundle, including the published +registry ones, advertised no languages at all. + +There were two defects: the missing property, and the fact that the whole block +sat inside `if repo_path and repo_path.exists()`, so an unscoped whole-graph +export never set the key even once the property existed. +""" + +from pathlib import Path + +import pytest + +pytest.importorskip("kuzu") + +from codegraphcontext.core.database_kuzu import KuzuDBManager +from codegraphcontext.tools.indexing.persistence.writer import GraphWriter + + +def _fresh_kuzu_manager(db_path: Path) -> KuzuDBManager: + if KuzuDBManager._instance is not None: + KuzuDBManager._instance.close_driver() + KuzuDBManager._instance = None + KuzuDBManager._db = None + KuzuDBManager._conn = None + return KuzuDBManager(db_path=str(db_path)) + + +def _file_data(path: str, lang: str): + return { + "path": path, + "name": Path(path).name, + "is_dependency": False, + "lang": lang, + "functions": [{"name": f"fn_{lang}", "line_number": 1, "context": None}], + "classes": [], + "variables": [], + "imports": [], + "function_calls": [], + } + + +def test_file_node_records_its_language(tmp_path): + manager = _fresh_kuzu_manager(tmp_path / "lang-db") + try: + driver = manager.get_driver() + GraphWriter(driver).add_file_to_graph( + _file_data("/repo/Main.kt", "kotlin"), "repo", {}, repo_path_str="/repo" + ) + + with driver.session() as session: + row = session.run( + "MATCH (f:File {name: $n}) RETURN f.language AS language", n="Main.kt" + ).single() + + assert row["language"] == "kotlin" + finally: + manager.close_driver() + + +def test_languages_are_distinct_across_a_mixed_repository(tmp_path): + """The export groups File nodes by language; each should appear once.""" + manager = _fresh_kuzu_manager(tmp_path / "lang-multi-db") + try: + driver = manager.get_driver() + writer = GraphWriter(driver) + for name, lang in [ + ("a.py", "python"), + ("b.kt", "kotlin"), + ("c.js", "javascript"), + ("d.py", "python"), + ]: + writer.add_file_to_graph( + _file_data(f"/repo/{name}", lang), "repo", {}, repo_path_str="/repo" + ) + + with driver.session() as session: + rows = session.run( + "MATCH (f:File) RETURN f.language AS language, count(*) AS count" + ) + languages = {r["language"]: r["count"] for r in rows if r["language"]} + + assert sorted(languages) == ["javascript", "kotlin", "python"] + assert languages["python"] == 2 + finally: + manager.close_driver() + + +def test_language_survives_the_kuzu_property_allow_list(tmp_path): + """`language` must be in SCHEMA_MAP['File']. + + The Kùzu backend filters node properties against an allow-list and drops + unknown ones silently, so a schema column alone is not sufficient -- this is + the step that would fail without the allow-list entry, and it would fail + quietly. + """ + manager = _fresh_kuzu_manager(tmp_path / "lang-allowlist-db") + try: + driver = manager.get_driver() + GraphWriter(driver).add_file_to_graph( + _file_data("/repo/x.rs", "rust"), "repo", {}, repo_path_str="/repo" + ) + with driver.session() as session: + row = session.run( + "MATCH (f:File {name: $n}) RETURN f.language AS language", n="x.rs" + ).single() + assert row["language"] == "rust", ( + "language was dropped between the writer and storage -- check the " + "File entry in SCHEMA_MAP" + ) + finally: + manager.close_driver()