Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
7 changes: 6 additions & 1 deletion .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ on:

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}

steps:
- name: Check out code
Expand All @@ -25,6 +29,7 @@ jobs:
pip install -e .[dev]

- name: Run end-to-end tests
shell: bash
run: |
chmod +x tests/run_tests.sh
./tests/run_tests.sh e2e
2 changes: 1 addition & 1 deletion CGC_REPORT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CGC Report

_Generated: 2026-08-13 18:25 UTC_
_Generated: 2026-08-18 17:20 UTC_


## God Nodes — Highest Fan-In
Expand Down
21 changes: 18 additions & 3 deletions src/codegraphcontext/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3023,10 +3023,19 @@ def analyze_dead_code(
# `path` was accepted and then dropped, so running inside one repository
# still reported dead code from every repository in the database.
repo_path = Path(path).resolve().as_posix() if path else None
results = code_finder.find_dead_code(exclude_list, repo_path=repo_path)
# find_dead_code used to cap itself at 50 rows inside the query, which
# doubled as this table's page size by accident. Now that it returns
# the full set, ask for a page explicitly -- otherwise a real codebase
# (7,141 dead functions was the reported figure) would print thousands
# of table rows. The true count comes from total_count below (#1606).
display_limit = get_tool_result_limit("find_dead_code")
results = code_finder.find_dead_code(
exclude_list, repo_path=repo_path, limit=display_limit
)

unused_funcs = results.get('potentially_unused_functions', [])

total_count = results.get('total_count', len(unused_funcs))

if not unused_funcs:
console.print("[green]✓ No dead code found![/green]")
return
Expand All @@ -3047,7 +3056,13 @@ def analyze_dead_code(

console.print("\n[bold yellow]⚠️ Potentially Unused Functions:[/bold yellow]")
console.print(table)
console.print(f"\n[dim]Total: {len(unused_funcs)} function(s)[/dim]")
if total_count > len(unused_funcs):
console.print(
f"\n[dim]Total: {total_count} function(s); "
f"showing the first {len(unused_funcs)} by path[/dim]"
)
else:
console.print(f"\n[dim]Total: {total_count} function(s)[/dim]")
console.print(f"[dim]Note: {results.get('note', '')}[/dim]")
finally:
db_manager.close_driver()
Expand Down
171 changes: 140 additions & 31 deletions src/codegraphcontext/core/cgc_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,75 @@ def _validate_cypher_identifier(value: Any, kind: str) -> str:
return value


# Repo-scoped export rewrites the repository root to "." and every path under
# it to "./rel" (#1509). Import must invert that rewrite against a per-bundle
# destination root; otherwise every second bundle collides on path="." and
# File keys like ./README.md.
_BUNDLE_RELATIVE_ROOTS = {".", "./", ".\\"}
_NON_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+")


def _sanitize_bundle_repo_slug(repo: Optional[str]) -> str:
"""Turn metadata['repo'] into a single path segment (pallets/flask → pallets__flask)."""
if not repo or not isinstance(repo, str):
return "unknown"
slug = repo.strip().replace("\\", "/").replace("..", "").strip("/")
slug = _NON_SLUG_RE.sub("__", slug).strip("_")
return slug or "unknown"


def _default_bundle_install_root(
metadata: Optional[Dict[str, Any]] = None,
destination_root: Optional[Path] = None,
) -> str:
"""Absolute posix dest root for one imported bundle: ~/.codegraphcontext/bundles/<slug>."""
slug = _sanitize_bundle_repo_slug((metadata or {}).get("repo"))
base = Path(destination_root) if destination_root is not None else (
Path.home() / ".codegraphcontext" / "bundles"
)
return (base / slug).resolve().as_posix()


def _is_bundle_relative_path(val: Any) -> bool:
"""True for portable export paths: '.', './', '.\\', './foo', '.\\foo'."""
if not isinstance(val, str) or not val:
return False
if val in _BUNDLE_RELATIVE_ROOTS:
return True
return val.startswith("./") or val.startswith(".\\")


def _is_identifying_repo_path(repo_path: Optional[str]) -> bool:
"""False for None, empty, and bundle-relative paths that are not unique."""
if not repo_path:
return False
return not _is_bundle_relative_path(repo_path)


def _rebase_bundle_path(val: str, dest_root: str) -> str:
"""Map '.' / './rel' onto dest_root. Non-relative strings are returned unchanged."""
if not _is_bundle_relative_path(val):
return val
if val in _BUNDLE_RELATIVE_ROOTS:
return dest_root
rel = val[2:].replace("\\", "/").lstrip("/")
if not rel:
return dest_root
return dest_root + "/" + rel


def _rebase_property_map(
props: Optional[Dict[str, Any]], dest_root: Optional[str]
) -> Dict[str, Any]:
"""Rewrite every bundle-relative string property in *props* in place."""
if not props or not dest_root:
return props or {}
for key, val in list(props.items()):
if isinstance(val, str) and _is_bundle_relative_path(val):
props[key] = _rebase_bundle_path(val, dest_root)
return props


class CGCBundle:
"""Handles creation and loading of .cgc bundle files."""

Expand Down Expand Up @@ -259,6 +328,7 @@ def import_from_bundle(
password: Optional[str] = None,
verify_key: Optional[str] = None,
graph_name: str = None,
destination_root: Optional[Path] = None,
) -> Tuple[bool, str]:
self._active_graph = graph_name
"""
Expand All @@ -268,6 +338,11 @@ def import_from_bundle(
bundle_path: Path to the .cgc file
clear_existing: Whether to clear existing graph data first
readonly: If True, mount as read-only (future feature)
password: Optional password to decrypt an encrypted bundle
verify_key: Optional HMAC key to verify a signed bundle
destination_root: Optional base directory used to re-absolutize
repo-scoped relative paths ('.' / './…'). Defaults to
~/.codegraphcontext/bundles/<sanitized repo>.

Returns:
Tuple[bool, str]: (success, message)
Expand Down Expand Up @@ -304,10 +379,15 @@ def import_from_bundle(

info_logger(f"Loading bundle: {metadata.get('repo', 'unknown')}")
info_logger(f"Bundle version: {metadata.get('cgc_version', 'unknown')}")

dest_root = _default_bundle_install_root(metadata, destination_root)

# Step 4: Handle existing data
repo_name = metadata.get('repo', 'unknown')
repo_path = metadata.get('repo_path')
if _is_bundle_relative_path(repo_path):
repo_path = _rebase_bundle_path(repo_path, dest_root)
metadata["repo_path"] = repo_path

if clear_existing:
# User explicitly wants to clear - remove everything
Expand All @@ -330,11 +410,11 @@ def import_from_bundle(

# Step 6: Import nodes
info_logger("Importing nodes...")
node_count = self._import_nodes(payload_path / "nodes.jsonl")
node_count = self._import_nodes(payload_path / "nodes.jsonl", dest_root)

# Step 7: Import edges
info_logger("Importing edges...")
edge_count = self._import_edges(payload_path / "edges.jsonl")
edge_count = self._import_edges(payload_path / "edges.jsonl", dest_root)

success_msg = f"✅ Successfully imported {bundle_path.name}\n"
success_msg += f" Repository: {metadata.get('repo', 'unknown')}\n"
Expand Down Expand Up @@ -1310,8 +1390,10 @@ def _check_existing_repository(self, repo_name: str, repo_path: Optional[str]) -
if result.single():
return True

# If repo_path is provided, also check by path
if repo_path:
# Path '.' / './…' is the portable export of every repo-scoped
# bundle, not a unique identity — matching on it refuses flask
# then requests as duplicates (#1509).
if _is_identifying_repo_path(repo_path):
result = session.run(
"MATCH (r:Repository {path: $path}) RETURN r LIMIT 1",
path=repo_path
Expand All @@ -1338,6 +1420,13 @@ def _delete_repository(self, repo_identifier: str):
return

repo_path = record['path']
if not _is_identifying_repo_path(repo_path):
warning_logger(
f"Refusing to delete repository '{repo_identifier}': "
f"path {repo_path!r} is not identifying and would match "
"every repo-scoped bundle import"
)
return

repo_prefix = repo_path if repo_path.endswith("/") else f"{repo_path}/"
# Delete all nodes that belong to this repository
Expand Down Expand Up @@ -1387,7 +1476,7 @@ def _import_schema(self, schema_file: Path):
# This is a placeholder for future enhancement
debug_log("Schema import not yet implemented - relying on application schema")

def _import_nodes(self, nodes_file: Path) -> int:
def _import_nodes(self, nodes_file: Path, dest_root: Optional[str] = None) -> int:
"""Import nodes from JSONL file."""
count = 0
batch_size = 1000
Expand Down Expand Up @@ -1416,12 +1505,12 @@ def _import_nodes(self, nodes_file: Path) -> int:
batch.append((labels, node_data, old_id))

if len(batch) >= batch_size:
count += self._import_node_batch(session, batch, id_mapping)
count += self._import_node_batch(session, batch, id_mapping, dest_root)
batch = []

# Import remaining nodes
if batch:
count += self._import_node_batch(session, batch, id_mapping)
count += self._import_node_batch(session, batch, id_mapping, dest_root)

# Store ID mapping for edge import
self._id_mapping = id_mapping
Expand Down Expand Up @@ -1450,26 +1539,32 @@ def _import_nodes(self, nodes_file: Path) -> int:
'DbTable': 'name', 'Datasource': 'name', 'ExternalClass': 'name',
}
_UID_PARTS = {
'Function': ['name', 'path', 'line_number'],
'Class': ['name', 'path', 'line_number'],
'Variable': ['name', 'path', 'line_number'],
'Trait': ['name', 'path', 'line_number'],
'Interface': ['name', 'path', 'line_number'],
'Macro': ['name', 'path', 'line_number'],
'Struct': ['name', 'path', 'line_number'],
'Enum': ['name', 'path', 'line_number'],
'Union': ['name', 'path', 'line_number'],
'Function': ['name', 'path', 'line_number', 'occurrence_index'],
'Class': ['name', 'path', 'line_number', 'occurrence_index'],
'Variable': ['name', 'path', 'line_number', 'occurrence_index'],
'Trait': ['name', 'path', 'line_number', 'occurrence_index'],
'Interface': ['name', 'path', 'line_number', 'occurrence_index'],
'Macro': ['name', 'path', 'line_number', 'occurrence_index'],
'Struct': ['name', 'path', 'line_number', 'occurrence_index'],
'Enum': ['name', 'path', 'line_number', 'occurrence_index'],
'Union': ['name', 'path', 'line_number', 'occurrence_index'],
'Annotation': ['name', 'path', 'line_number'],
'Record': ['name', 'path', 'line_number'],
'Property': ['name', 'path', 'line_number'],
'Record': ['name', 'path', 'line_number', 'occurrence_index'],
'Property': ['name', 'path', 'line_number', 'occurrence_index'],
'Parameter': ['name', 'path', 'function_line_number'],
'EnumMember': ['name', 'path', 'line_number'],
'Mixin': ['name', 'path', 'line_number'],
'Extension': ['name', 'path', 'line_number'],
'Object': ['name', 'path', 'line_number'],
'EnumMember': ['name', 'path', 'line_number', 'occurrence_index'],
'Mixin': ['name', 'path', 'line_number', 'occurrence_index'],
'Extension': ['name', 'path', 'line_number', 'occurrence_index'],
'Object': ['name', 'path', 'line_number', 'occurrence_index'],
}

def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> int:
def _import_node_batch(
self,
session,
batch: List[Tuple],
id_mapping: Dict,
dest_root: Optional[str] = None,
) -> int:
"""Import a batch of nodes."""
id_function = self._get_id_function()

Expand All @@ -1483,10 +1578,20 @@ def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> i
label_str = ':'.join(labels)
primary_label = labels[0]

if dest_root:
_rebase_property_map(properties, dest_root)

pk_field = self._PK_MAP.get(primary_label)
if pk_field == 'uid' and 'uid' not in properties:
parts = self._UID_PARTS.get(primary_label, [])
properties['uid'] = ''.join(str(properties.get(p, '')) for p in parts)
parts = self._UID_PARTS.get(primary_label, []) if pk_field == 'uid' else []
# After rebasing './…' paths, uid must include the dest root so
# Function/Class MERGE keys do not collide across bundles (#1509).
if pk_field == 'uid' and parts and (dest_root or 'uid' not in properties):
properties['uid'] = ''.join(
# occurrence_index defaults to 0 to match the writer's uid
# for bundles exported before #1393 added the property.
str(properties.get(p, 0 if p == 'occurrence_index' else ''))
for p in parts
)

if pk_field and pk_field in properties:
pk_val = properties[pk_field]
Expand All @@ -1511,7 +1616,7 @@ def _import_node_batch(self, session, batch: List[Tuple], id_mapping: Dict) -> i

return len(batch)

def _import_edges(self, edges_file: Path) -> int:
def _import_edges(self, edges_file: Path, dest_root: Optional[str] = None) -> int:
"""Import edges from JSONL file."""
count = 0
batch_size = 1000
Expand All @@ -1524,16 +1629,18 @@ def _import_edges(self, edges_file: Path) -> int:
batch.append(edge_data)

if len(batch) >= batch_size:
count += self._import_edge_batch(session, batch)
count += self._import_edge_batch(session, batch, dest_root)
batch = []

# Import remaining edges
if batch:
count += self._import_edge_batch(session, batch)
count += self._import_edge_batch(session, batch, dest_root)

return count

def _import_edge_batch(self, session, batch: List[Dict]) -> int:
def _import_edge_batch(
self, session, batch: List[Dict], dest_root: Optional[str] = None
) -> int:
"""Import a batch of edges."""
id_mapping = getattr(self, '_id_mapping', {})
# Detect database backend to use appropriate ID function
Expand All @@ -1548,7 +1655,9 @@ def _import_edge_batch(self, session, batch: List[Dict]) -> int:
if isinstance(old_to, dict):
old_to = (old_to.get('table', 0), old_to.get('offset', 0))
rel_type = _validate_cypher_identifier(edge.get('type'), "relationship type")
properties = edge.get('properties', {})
properties = edge.get('properties', {}) or {}
if dest_root:
properties = _rebase_property_map(properties, dest_root)

# Map old IDs to new IDs
new_from = id_mapping.get(old_from)
Expand Down
Loading
Loading