Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/codegraphcontext/cli/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ def _atomic_write_text(path: Path, content: str, *, secure: bool = False) -> Non
build/
target/
out/
obj/
.git/
.idea/
.vscode/
Expand Down
7 changes: 5 additions & 2 deletions src/codegraphcontext/core/database_embedded_kuzu.py
Original file line number Diff line number Diff line change
Expand Up @@ -1200,8 +1200,11 @@ def not_label_replacer(match):
return f"{prefix}NOT label({var_name}) = '{label_name}'"
query = re.sub(r'(WHERE\s+|AND\s+|OR\s+)NOT\s+(\w+):([a-zA-Z0-9_`]+)', not_label_replacer, query, flags=re.IGNORECASE)

# 4. Polymorphic matches and label access
query = query.replace("labels(n)[0]", "label(n)")
# 4. Polymorphic matches and label access. Kùzu has no labels();
# rewrite labels(<var>)[0] for ANY variable name — the literal
# "labels(n)[0]" form missed n1/n2 and the simulator's relationship
# query reached the engine unsupported (#1512).
query = re.sub(r'labels\((\w+)\)\s*\[\s*0\s*\]', r'label(\1)', query)

query = query.replace("coalesce(", "COALESCE(")
query = re.sub(r'\btype\(', 'label(', query)
Expand Down
31 changes: 27 additions & 4 deletions src/codegraphcontext/core/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class JobInfo:
result: Optional[Dict[str, Any]] = None
path: Optional[str] = None
is_dependency: bool = False
last_update_time: Optional[datetime] = None

def __post_init__(self):
"""Ensures the errors list is initialized after the object is created."""
Expand Down Expand Up @@ -93,6 +94,7 @@ def update_job(self, job_id: str, **kwargs):
for key, value in kwargs.items():
if hasattr(job, key):
setattr(job, key, value)
job.last_update_time = datetime.now()

def get_job(self, job_id: str) -> Optional[JobInfo]:
"""Retrieves the information for a single job."""
Expand Down Expand Up @@ -132,15 +134,36 @@ def list_active_jobs(self) -> List[JobInfo]:
def cleanup_old_jobs(self, max_age_hours: int = 24):
"""Removes old jobs from memory to prevent memory leaks.

Finished jobs are aged by end_time; jobs that never finished (e.g.
crashed while PENDING/RUNNING) are aged by start_time so they don't
leak forever.
Terminal jobs (COMPLETED/FAILED/CANCELLED) are aged by end_time,
falling back to start_time.

Jobs still PENDING/RUNNING are never deleted outright (#1537): a
healthy long-running index updates its progress constantly, and
deleting its record makes update_job silently no-op and
check_job_status report not_found while the worker keeps writing.
Instead, an active job with *no progress update* for the whole window
is presumed crashed and flipped to FAILED — the record stays visible,
and the next sweep ages it out through the terminal path.
"""
cutoff_time = datetime.now() - timedelta(hours=max_age_hours)
with self.lock:
active = (JobStatus.PENDING, JobStatus.RUNNING)
jobs_to_remove = [
job_id for job_id, job in self.jobs.items()
if (job.end_time or job.start_time) < cutoff_time
if job.status not in active
and (job.end_time or job.start_time) < cutoff_time
]
for job_id in jobs_to_remove:
del self.jobs[job_id]

for job in self.jobs.values():
if job.status in active:
last_progress = job.last_update_time or job.start_time
if last_progress < cutoff_time:
job.status = JobStatus.FAILED
job.end_time = datetime.now()
if job.errors is None:
job.errors = []
job.errors.append(
f"Presumed crashed: no progress update for {max_age_hours}h"
)
6 changes: 4 additions & 2 deletions src/codegraphcontext/core/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ def load_from_db(self, db_manager) -> "CodeGraphTwin":
"""

# 2. Fetch all relationships originating from nodes in the repository
# `*0..` includes the repository node itself (zero-length path), which
# replaces the old `WHERE n1 = r OR (r)-[:CONTAINS*]->(n1)` form — a
# bare pattern predicate the embedded backends cannot plan (#1512).
rel_query = """
MATCH (r:Repository {path: $path})
MATCH (n1) WHERE n1 = r OR (r)-[:CONTAINS*]->(n1)
MATCH (r:Repository {path: $path})-[:CONTAINS*0..]->(n1)
MATCH (n1)-[rel]->(n2)
RETURN
labels(n1)[0] as source_label,
Expand Down
7 changes: 7 additions & 0 deletions src/codegraphcontext/core/watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,13 @@ def stop(self):
h.cancel_timers()
self.handlers.clear()

# A stopped watchdog Observer cannot be restarted, so the watch state
# must not survive it: a stale entry makes watch_directory answer
# "Already watching" for a dead observer and the graph silently goes
# stale (#1519).
self.watched_paths.clear()
self.watches.clear()

if self.observer.is_alive():
self.observer.stop()
self.observer.join()
29 changes: 10 additions & 19 deletions src/codegraphcontext/tools/graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class GraphBuilder:
linking for Neo4j, FalkorDB, and Kùzu backends.
"""

def __init__(self, db_manager: DatabaseManager, job_manager: JobManager, loop: asyncio.AbstractEventLoop):
def __init__(self, db_manager: DatabaseManager, job_manager: JobManager, loop: asyncio.AbstractEventLoop, graph_name: str = None):
"""Initialize GraphBuilder.

Parameters
Expand All @@ -50,11 +50,16 @@ def __init__(self, db_manager: DatabaseManager, job_manager: JobManager, loop: a
Job manager for tracking indexing progress.
loop : asyncio.AbstractEventLoop
Event loop for async operations.
graph_name : str, optional
Named graph to write into, on backends that support multiple
graphs per instance (FalkorDB). None selects the default graph
(#1558).
"""
self.db_manager = db_manager
self.job_manager = job_manager
self.loop = loop
self._writer = GraphWriter(self.db_manager.get_driver(), db_manager=self.db_manager)
self.graph_name = graph_name
self._writer = GraphWriter(self.db_manager.get_driver(graph_name), db_manager=self.db_manager)
self.last_call_resolution_diagnostics: list[Dict[str, Any]] = []
self.last_index_summary: Dict[str, Any] = {}
self.parsers = {
Expand Down Expand Up @@ -118,8 +123,9 @@ def __init__(self, db_manager: DatabaseManager, job_manager: JobManager, loop: a

@property
def driver(self):
"""Default graph driver (backward compatible)."""
return self.db_manager.get_driver()
"""Driver for this builder's graph (default graph unless a
graph_name was given at construction)."""
return self.db_manager.get_driver(self.graph_name)

def _driver_for(self, graph_name: str = None):
"""Get driver for a specific graph, or default."""
Expand Down Expand Up @@ -184,21 +190,6 @@ def pre_scan_imports(self, files: list[Path]) -> dict:


# Language-agnostic method
def add_repository_to_graph(self, repo_path: Path, is_dependency: bool = False):
"""Adds a repository node using its absolute path as the unique key."""
repo_name = repo_path.name
repo_path_str = repo_path.resolve().as_posix()
with self.driver.session() as session:
session.run(
"""
MERGE (r:Repository {path: $path})
SET r.name = $name, r.is_dependency = $is_dependency
""",
path=repo_path_str,
name=repo_name,
is_dependency=is_dependency,
)

def add_repository_to_graph(self, repo_path: Path, is_dependency: bool = False) -> None:
"""Add a repository node to the graph.

Expand Down
47 changes: 28 additions & 19 deletions src/codegraphcontext/tools/handlers/indexing_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,29 @@ def add_code_to_graph(graph_builder, job_manager, loop, list_repos_func, **args)
if not path:
return {"error": "Path is a required argument (repo_path)."}

# `graph_name` is advertised in this tool's schema but indexing cannot yet
# honour it: GraphBuilder binds its GraphWriter to the default driver at
# construction, so a named graph would need a per-job writer threaded
# through the whole pipeline. Refuse explicitly rather than write to the
# default graph and report success — an agent that believed the repository
# landed in a named graph would find nothing there later, with no error to
# explain it.
# Indexing into a named graph is supported on multi-graph backends
# (FalkorDB): a scoped GraphBuilder is constructed whose writer binds to
# get_driver(graph_name). Single-graph backends (KùzuDB/LadybugDB/Neo4j)
# would silently ignore the name and land the repo in the default graph —
# the exact lie #1558 is about — so those still refuse explicitly.
graph_name = args.get("graph_name")
if graph_name:
return {
"error": (
f"Indexing into a named graph is not supported yet, so "
f"graph_name={graph_name!r} cannot be honoured. Omit it to index "
"into the default graph, or select the graph via the CLI context "
"(`cgc context create` / `cgc index --context`)."
),
"unsupported_argument": "graph_name",
}
backend = graph_builder.db_manager.get_backend_type()
if backend in ("falkordb", "falkordb-remote"):
from ..graph_builder import GraphBuilder
graph_builder = GraphBuilder(
graph_builder.db_manager, job_manager, loop, graph_name=graph_name
)
else:
return {
"error": (
f"The active backend ({backend}) is single-graph, so "
f"graph_name={graph_name!r} cannot be honoured. Omit it to "
"index into the default graph, or select a graph via the CLI "
"context (`cgc context create` / `cgc index --context`)."
),
"unsupported_argument": "graph_name",
}

try:
path_obj = Path(path).resolve()
Expand All @@ -61,8 +66,10 @@ def add_code_to_graph(graph_builder, job_manager, loop, list_repos_func, **args)
"message": f"Path '{path}' does not exist.",
}

# Prevent re-indexing the same repository.
indexed_repos = list_repos_func().get("repositories", [])
# Prevent re-indexing the same repository. list_repos_func reads the
# default graph, so the check only applies there — a named graph is a
# separate namespace and legitimately re-indexes the same path.
indexed_repos = [] if graph_name else list_repos_func().get("repositories", [])
for repo in indexed_repos:
if repo_record_matches_path(repo, path_obj):
return {
Expand All @@ -85,7 +92,9 @@ def add_code_to_graph(graph_builder, job_manager, loop, list_repos_func, **args)

return {
"success": True, "job_id": job_id,
"message": f"Background processing started for {str(path_obj)}",
**({"graph_name": graph_name} if graph_name else {}),
"message": f"Background processing started for {str(path_obj)}"
+ (f" into graph '{graph_name}'" if graph_name else ""),
"estimated_files": total_files,
"estimated_duration_seconds": round(estimated_time, 2),
"estimated_duration_human": f"{int(estimated_time // 60)}m {int(estimated_time % 60)}s" if estimated_time >= 60 else f"{int(estimated_time)}s",
Expand Down
3 changes: 3 additions & 0 deletions src/codegraphcontext/tools/indexing/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"build/",
"target/",
"out/",
# .NET intermediate output: contains *generated* source (AssemblyInfo.cs,
# GlobalUsings.g.cs) that pollutes the graph with phantom modules (#1585).
"obj/",
".git/",
"__pycache__/",
"*.png",
Expand Down
61 changes: 36 additions & 25 deletions src/codegraphcontext/tools/languages/javascript.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,38 +505,49 @@ def _find_imports(self, root_node):
if node.type == 'import_statement':
source = self._get_node_text(node.child_by_field_name('source')).strip('\'"')

# Look for different import structures
import_clause = node.child_by_field_name('import')
# `import_clause` is an unnamed child of import_statement, not a
# field, so child_by_field_name('import') always returned None and
# every ES import collapsed into the bare-module fallback (#1526).
import_clause = next(
(c for c in node.children if c.type == 'import_clause'), None
)
if not import_clause:
# Side-effect import: import 'polyfill';
imports.append({'name': source, 'source': source, 'alias': None, 'line_number': line_number,
'lang': self.language_name})
continue

# Default import: import defaultExport from '...'
if import_clause.type == 'identifier':
alias = self._get_node_text(import_clause)
imports.append({'name': 'default', 'source': source, 'alias': alias, 'line_number': line_number,
'lang': self.language_name})

# Namespace import: import * as name from '...'
elif import_clause.type == 'namespace_import':
alias_node = import_clause.child_by_field_name('alias')
if alias_node:
alias = self._get_node_text(alias_node)
imports.append({'name': '*', 'source': source, 'alias': alias, 'line_number': line_number,
# One clause can carry several bindings at once
# (import Def, { a as b } from 'mod'), so walk its children.
for binding in import_clause.children:
# Default import: import defaultExport from '...'
if binding.type == 'identifier':
alias = self._get_node_text(binding)
imports.append({'name': 'default', 'source': source, 'alias': alias, 'line_number': line_number,
'lang': self.language_name})

# Named imports: import { name, name as alias } from '...'
elif import_clause.type == 'named_imports':
for specifier in import_clause.children:
if specifier.type == 'import_specifier':
name_node = specifier.child_by_field_name('name')
alias_node = specifier.child_by_field_name('alias')
original_name = self._get_node_text(name_node)
alias = self._get_node_text(alias_node) if alias_node else None
imports.append(
{'name': original_name, 'source': source, 'alias': alias, 'line_number': line_number,
'lang': self.language_name})
# Namespace import: import * as name from '...' — the alias
# is the identifier child (there is no 'alias' field).
elif binding.type == 'namespace_import':
alias = next(
(self._get_node_text(c) for c in binding.children if c.type == 'identifier'),
None,
)
if alias:
imports.append({'name': '*', 'source': source, 'alias': alias, 'line_number': line_number,
'lang': self.language_name})

# Named imports: import { name, name as alias } from '...'
elif binding.type == 'named_imports':
for specifier in binding.children:
if specifier.type == 'import_specifier':
name_node = specifier.child_by_field_name('name')
alias_node = specifier.child_by_field_name('alias')
original_name = self._get_node_text(name_node)
alias = self._get_node_text(alias_node) if alias_node else None
imports.append(
{'name': original_name, 'source': source, 'alias': alias, 'line_number': line_number,
'lang': self.language_name})

elif node.type == 'call_expression': # require('...')
args = node.child_by_field_name('arguments')
Expand Down
Loading
Loading