From 4695c687848bc6dc4f6696fb7f4b95a72dbc355b Mon Sep 17 00:00:00 2001 From: Shashank Shekhar Singh Date: Fri, 14 Aug 2026 00:43:03 +0530 Subject: [PATCH] fix(graph): populate bundle metadata languages Two defects meant every exported bundle advertised no languages. 1. Bundle export reads f.language on File nodes, but no writer ever set it and the Kuzu File table had no such column. The `if record["language"]` guard then filtered every row out. The per-file language is already known at write time as file_data['lang'], so persist it -- plus the schema column, a migration for existing databases, and the SCHEMA_MAP allow-list entry, without which Kuzu drops the property silently. 2. The language block sat inside `if repo_path and repo_path.exists()`, so a whole-graph export never set the key at all, even with the property present. Moved out and given an unscoped query branch. Fixes #1516. Co-Authored-By: Claude Opus 5 (1M context) --- src/codegraphcontext/core/cgc_bundle.py | 23 +++- .../core/database_embedded_kuzu.py | 5 +- .../tools/indexing/persistence/writer.py | 6 +- .../core/test_bundle_languages_metadata.py | 115 ++++++++++++++++++ 4 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 tests/unit/core/test_bundle_languages_metadata.py 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()