codegraph-engine 3.0.0__1-py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +859 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/git_state.py ADDED
@@ -0,0 +1,545 @@
1
+ """Git state tracking and freshness engine.
2
+
3
+ Tracks:
4
+ - current branch
5
+ - current HEAD commit SHA
6
+ - indexed HEAD commit SHA
7
+ - working-tree state (modified, staged, untracked relevant files, deleted)
8
+ - explicit freshness states: CLEAN, DIRTY, STALE, REINDEXING, ERROR
9
+
10
+ Enforces:
11
+ - sub-50ms quick freshness checks
12
+ - incremental synchronization of affected graph state without full re-indexing
13
+ - deterministic evidence-backed status reporting
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import sqlite3
18
+ import subprocess
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from enum import StrEnum
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from codegraph.errors import SecurityError
26
+ from codegraph.security import safe_path
27
+
28
+
29
+ class GitFreshnessState(StrEnum):
30
+ CLEAN = "CLEAN" # Current HEAD == indexed HEAD, working tree clean
31
+ DIRTY = "DIRTY" # Current HEAD == indexed HEAD, but uncommitted working tree changes
32
+ STALE = "STALE" # Current HEAD != indexed HEAD (commits advanced or branch switched)
33
+ REINDEXING = "REINDEXING" # Index lock active / reindexing underway
34
+ ERROR = "ERROR" # Not a git repo, git binary missing, or fatal error
35
+
36
+
37
+ @dataclass
38
+ class WorkingTreeStatus:
39
+ is_dirty: bool
40
+ modified_files: list[str] = field(default_factory=list)
41
+ staged_files: list[str] = field(default_factory=list)
42
+ untracked_files: list[str] = field(default_factory=list)
43
+ deleted_files: list[str] = field(default_factory=list)
44
+ renamed_files: list[dict[str, str]] = field(default_factory=list)
45
+
46
+ def as_dict(self) -> dict[str, Any]:
47
+ return {
48
+ "is_dirty": self.is_dirty,
49
+ "modified_files": self.modified_files,
50
+ "staged_files": self.staged_files,
51
+ "untracked_files": self.untracked_files,
52
+ "deleted_files": self.deleted_files,
53
+ "renamed_files": self.renamed_files,
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class GitStateReport:
59
+ is_git: bool
60
+ branch: str | None
61
+ current_head: str | None
62
+ indexed_head: str | None
63
+ freshness: GitFreshnessState
64
+ working_tree: WorkingTreeStatus
65
+ reindex_required: bool
66
+ detail: str = ""
67
+ last_indexed_at: int | None = None
68
+ affected_files_count: int = 0
69
+
70
+ def as_dict(self) -> dict[str, Any]:
71
+ return {
72
+ "is_git": self.is_git,
73
+ "branch": self.branch,
74
+ "current_head": self.current_head,
75
+ "indexed_head": self.indexed_head,
76
+ "freshness": self.freshness.value,
77
+ "working_tree": self.working_tree.as_dict(),
78
+ "reindex_required": self.reindex_required,
79
+ "detail": self.detail,
80
+ "last_indexed_at": self.last_indexed_at,
81
+ "affected_files_count": self.affected_files_count,
82
+ }
83
+
84
+
85
+ def _run_git_cmd(repository: Path, *args: str, timeout: int = 10) -> str | None:
86
+ """Execute an allowlisted git command safely within repository boundary."""
87
+ for arg in args:
88
+ if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
89
+ return None
90
+ try:
91
+ res = subprocess.run(
92
+ ["git", *args],
93
+ cwd=str(repository),
94
+ capture_output=True,
95
+ text=True,
96
+ encoding="utf-8",
97
+ errors="replace",
98
+ timeout=timeout,
99
+ )
100
+ return res.stdout if res.returncode == 0 else None
101
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
102
+ return None
103
+
104
+
105
+ def get_indexed_commit(con: sqlite3.Connection | None) -> str | None:
106
+ """Read indexed commit SHA from metadata table if available."""
107
+ if con is None:
108
+ return None
109
+ for table in ("metadata", "codegraph_meta"):
110
+ try:
111
+ row = con.execute(f"SELECT value FROM {table} WHERE key='indexed_commit'").fetchone()
112
+ if row and row[0]:
113
+ return str(row[0]).strip()
114
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
115
+ continue
116
+ return None
117
+
118
+
119
+ def get_last_indexed_timestamp(con: sqlite3.Connection | None) -> int | None:
120
+ """Read index_timestamp from metadata table if available."""
121
+ if con is None:
122
+ return None
123
+ for table in ("metadata", "codegraph_meta"):
124
+ try:
125
+ row = con.execute(f"SELECT value FROM {table} WHERE key='index_timestamp'").fetchone()
126
+ if row and row[0]:
127
+ return int(row[0])
128
+ except (sqlite3.OperationalError, sqlite3.DatabaseError, ValueError):
129
+ continue
130
+ return None
131
+
132
+
133
+ def is_reindexing_active(repository: Path) -> bool:
134
+ """Check if index lock or reindexing marker is currently active."""
135
+ lock_file = repository / ".codegraph" / "indexing.lock"
136
+ legacy_lock = repository / ".codegraph.lock"
137
+ return lock_file.exists() or legacy_lock.exists()
138
+
139
+
140
+ # Recognized source extensions for tracking relevant untracked files
141
+ RELEVANT_EXTENSIONS = {".py", ".ts", ".js", ".tsx", ".jsx", ".sql", ".prisma", ".json", ".toml", ".yaml", ".yml"}
142
+
143
+
144
+ def _fast_read_git_head_and_branch(repository: Path) -> tuple[str | None, str | None]:
145
+ """Pure-Python zero-subprocess resolution of HEAD commit SHA and current branch.
146
+
147
+ Resolves in <0.2ms on standard repositories, falling back to None if non-standard or missing.
148
+ """
149
+ git_dir = repository / ".git"
150
+ if git_dir.is_file():
151
+ # Git worktree or submodule pointing to another gitdir
152
+ try:
153
+ content = git_dir.read_text(encoding="utf-8", errors="replace").strip()
154
+ if content.startswith("gitdir:"):
155
+ target = Path(content.split(":", 1)[1].strip())
156
+ git_dir = target if target.is_absolute() else (repository / target).resolve()
157
+ except OSError:
158
+ return None, None
159
+ if not git_dir.exists() or not git_dir.is_dir():
160
+ return None, None
161
+
162
+ head_file = git_dir / "HEAD"
163
+ if not head_file.exists():
164
+ return None, None
165
+
166
+ try:
167
+ head_text = head_file.read_text(encoding="utf-8", errors="replace").strip()
168
+ if head_text.startswith("ref:"):
169
+ ref_path = head_text.split(":", 1)[1].strip()
170
+ branch = ref_path.replace("refs/heads/", "")
171
+ # Check loose ref
172
+ ref_file = git_dir / ref_path
173
+ if ref_file.exists():
174
+ return ref_file.read_text(encoding="utf-8", errors="replace").strip(), branch
175
+ # Check packed-refs
176
+ packed_file = git_dir / "packed-refs"
177
+ if packed_file.exists():
178
+ for line in packed_file.read_text(encoding="utf-8", errors="replace").splitlines():
179
+ if line.startswith("#") or line.startswith("^"):
180
+ continue
181
+ parts = line.strip().split()
182
+ if len(parts) == 2 and parts[1] == ref_path:
183
+ return parts[0], branch
184
+ return None, branch
185
+ elif len(head_text) == 40 and all(c in "0123456789abcdefABCDEF" for c in head_text):
186
+ return head_text, f"detached:{head_text[:8]}"
187
+ except OSError:
188
+ pass
189
+
190
+ return None, None
191
+
192
+
193
+ _WORKING_TREE_CACHE: dict[str, tuple[float, WorkingTreeStatus]] = {}
194
+
195
+
196
+ def clear_working_tree_cache() -> None:
197
+ """Clear cached working tree statuses."""
198
+ _WORKING_TREE_CACHE.clear()
199
+
200
+
201
+ def get_working_tree_status(repository: Path, max_age_ms: float = 0.0) -> WorkingTreeStatus:
202
+ """Inspect working tree status using git status porcelain v1 with optional sub-millisecond caching."""
203
+ repo_key = str(repository.resolve())
204
+ now = time.monotonic()
205
+ if max_age_ms > 0.0 and repo_key in _WORKING_TREE_CACHE:
206
+ cached_time, cached_val = _WORKING_TREE_CACHE[repo_key]
207
+ if (now - cached_time) * 1000.0 < max_age_ms:
208
+ return cached_val
209
+
210
+ raw = _run_git_cmd(repository, "status", "--porcelain=v1", "-uall")
211
+ if raw is None:
212
+ return WorkingTreeStatus(is_dirty=False)
213
+
214
+ modified: list[str] = []
215
+ staged: list[str] = []
216
+ untracked: list[str] = []
217
+ deleted: list[str] = []
218
+ renamed: list[dict[str, str]] = []
219
+
220
+ for line in raw.splitlines():
221
+ if len(line) < 4:
222
+ continue
223
+ index_status = line[0]
224
+ work_status = line[1]
225
+ path_part = line[3:].strip()
226
+
227
+ # Handle renamed format: "R old -> new"
228
+ if " -> " in path_part:
229
+ old_p, new_p = path_part.split(" -> ", 1)
230
+ old_p = old_p.strip().strip('"')
231
+ new_p = new_p.strip().strip('"')
232
+ renamed.append({"old_path": old_p, "new_path": new_p})
233
+ staged.append(new_p)
234
+ continue
235
+
236
+ clean_path = path_part.strip('"')
237
+
238
+ # Staged changes (index)
239
+ if index_status in ("M", "A", "R"):
240
+ if clean_path not in staged:
241
+ staged.append(clean_path)
242
+ elif index_status == "D":
243
+ if clean_path not in deleted:
244
+ deleted.append(clean_path)
245
+
246
+ # Working tree changes
247
+ if work_status == "M":
248
+ if clean_path not in modified:
249
+ modified.append(clean_path)
250
+ elif work_status == "D":
251
+ if clean_path not in deleted:
252
+ deleted.append(clean_path)
253
+ elif work_status == "?":
254
+ # Only include untracked if relevant source file
255
+ ext = Path(clean_path).suffix.lower()
256
+ if ext in RELEVANT_EXTENSIONS:
257
+ untracked.append(clean_path)
258
+
259
+ is_dirty = bool(modified or staged or untracked or deleted or renamed)
260
+ res = WorkingTreeStatus(
261
+ is_dirty=is_dirty,
262
+ modified_files=sorted(modified),
263
+ staged_files=sorted(staged),
264
+ untracked_files=sorted(untracked),
265
+ deleted_files=sorted(deleted),
266
+ renamed_files=renamed,
267
+ )
268
+ _WORKING_TREE_CACHE[repo_key] = (now, res)
269
+ return res
270
+
271
+
272
+ _DIFF_COUNT_CACHE: dict[tuple[str, str, str], int] = {}
273
+
274
+
275
+ def _get_cached_diff_count(repository: Path, base: str, head: str) -> int:
276
+ """Cache diff counts between immutable commit SHAs to avoid redundant subprocess invocations."""
277
+ cache_key = (str(repository), base, head)
278
+ if cache_key in _DIFF_COUNT_CACHE:
279
+ return _DIFF_COUNT_CACHE[cache_key]
280
+ diff_out = _run_git_cmd(repository, "diff", "--name-only", base, head)
281
+ count = len(diff_out.splitlines()) if diff_out else 0
282
+ if len(_DIFF_COUNT_CACHE) > 500:
283
+ _DIFF_COUNT_CACHE.clear()
284
+ _DIFF_COUNT_CACHE[cache_key] = count
285
+ return count
286
+
287
+
288
+ def get_git_state(
289
+ repository: Path,
290
+ con: sqlite3.Connection | None = None,
291
+ cache_ttl_ms: float = 0.0,
292
+ ) -> GitStateReport:
293
+ """Compute comprehensive Git state and freshness assessment."""
294
+ if is_reindexing_active(repository):
295
+ return GitStateReport(
296
+ is_git=True,
297
+ branch=None,
298
+ current_head=None,
299
+ indexed_head=get_indexed_commit(con),
300
+ freshness=GitFreshnessState.REINDEXING,
301
+ working_tree=WorkingTreeStatus(is_dirty=False),
302
+ reindex_required=False,
303
+ detail="Repository reindexing lock is currently active.",
304
+ last_indexed_at=get_last_indexed_timestamp(con),
305
+ )
306
+
307
+ # 1. Fast zero-subprocess check for Git directory, HEAD, and branch
308
+ current_head, branch = _fast_read_git_head_and_branch(repository)
309
+ is_git_confirmed = bool(current_head or branch or (repository / ".git").exists())
310
+
311
+ if not is_git_confirmed:
312
+ # Fallback check for non-standard GIT_DIR env or worktree
313
+ git_dir = _run_git_cmd(repository, "rev-parse", "--git-dir")
314
+ if git_dir is None:
315
+ return GitStateReport(
316
+ is_git=False,
317
+ branch=None,
318
+ current_head=None,
319
+ indexed_head=get_indexed_commit(con),
320
+ freshness=GitFreshnessState.ERROR,
321
+ working_tree=WorkingTreeStatus(is_dirty=False),
322
+ reindex_required=False,
323
+ detail="Repository is not a Git repository.",
324
+ last_indexed_at=get_last_indexed_timestamp(con),
325
+ )
326
+
327
+ # 2. If HEAD or branch was not resolved by fast-path, query Git CLI
328
+ if not current_head:
329
+ head_raw = _run_git_cmd(repository, "rev-parse", "HEAD")
330
+ current_head = head_raw.strip() if head_raw else None
331
+
332
+ if not branch:
333
+ branch_raw = _run_git_cmd(repository, "rev-parse", "--abbrev-ref", "HEAD")
334
+ branch = branch_raw.strip() if branch_raw else None
335
+ if branch == "HEAD":
336
+ branch = f"detached:{current_head[:8]}" if current_head else "detached"
337
+
338
+ indexed_head = get_indexed_commit(con)
339
+ last_indexed_at = get_last_indexed_timestamp(con)
340
+ wt_status = get_working_tree_status(repository, max_age_ms=cache_ttl_ms)
341
+
342
+ # 3. Determine freshness state
343
+ if indexed_head is None:
344
+ # Never indexed
345
+ freshness = GitFreshnessState.STALE
346
+ detail = "Repository has not been indexed yet."
347
+ reindex_required = True
348
+ affected_count = len(wt_status.modified_files) + len(wt_status.staged_files)
349
+ elif current_head != indexed_head:
350
+ # Commit advanced or branch switched
351
+ freshness = GitFreshnessState.STALE
352
+ detail = f"Indexed commit ({indexed_head[:8]}) does not match current HEAD ({current_head[:8] if current_head else 'none'})."
353
+ reindex_required = True
354
+ # Calculate diff files between indexed_head and current_head with caching
355
+ diff_count = _get_cached_diff_count(repository, indexed_head, current_head or "HEAD")
356
+ affected_count = diff_count + len(wt_status.modified_files) + len(wt_status.staged_files)
357
+ elif wt_status.is_dirty:
358
+ freshness = GitFreshnessState.DIRTY
359
+ detail = "Working tree has uncommitted modifications, staged changes, or relevant untracked files."
360
+ reindex_required = True
361
+ affected_count = len(wt_status.modified_files) + len(wt_status.staged_files) + len(wt_status.untracked_files) + len(wt_status.deleted_files)
362
+ else:
363
+ freshness = GitFreshnessState.CLEAN
364
+ detail = f"Index is fresh at HEAD ({current_head[:8] if current_head else 'clean'}) with clean working tree."
365
+ reindex_required = False
366
+ affected_count = 0
367
+
368
+ return GitStateReport(
369
+ is_git=True,
370
+ branch=branch,
371
+ current_head=current_head,
372
+ indexed_head=indexed_head,
373
+ freshness=freshness,
374
+ working_tree=wt_status,
375
+ reindex_required=reindex_required,
376
+ detail=detail,
377
+ last_indexed_at=last_indexed_at,
378
+ affected_files_count=affected_count,
379
+ )
380
+
381
+
382
+ @dataclass
383
+ class IncrementalSyncResult:
384
+ status: str
385
+ reindexed_files: list[str]
386
+ deleted_files: list[str]
387
+ new_commit: str | None
388
+ duration_ms: float
389
+ detail: str
390
+
391
+ def as_dict(self) -> dict[str, Any]:
392
+ return {
393
+ "status": self.status,
394
+ "reindexed_files": self.reindexed_files,
395
+ "deleted_files": self.deleted_files,
396
+ "new_commit": self.new_commit,
397
+ "duration_ms": self.duration_ms,
398
+ "detail": self.detail,
399
+ }
400
+
401
+
402
+ def incremental_git_sync(
403
+ repository: Path,
404
+ con: sqlite3.Connection,
405
+ ) -> IncrementalSyncResult:
406
+ """Incrementally synchronize the affected graph state without a full repository rebuild.
407
+
408
+ Identifies changed/added/deleted/renamed files and updates the index database
409
+ selectively, updating indexed_commit when working tree is clean.
410
+ """
411
+ import time
412
+
413
+ from codegraph.config import Settings
414
+ from codegraph.indexing import Indexer
415
+
416
+ start_time = time.perf_counter()
417
+ state = get_git_state(repository, con=con)
418
+
419
+ if not state.is_git:
420
+ return IncrementalSyncResult(
421
+ status="error",
422
+ reindexed_files=[],
423
+ deleted_files=[],
424
+ new_commit=None,
425
+ duration_ms=0.0,
426
+ detail="Cannot incrementally sync non-git repository.",
427
+ )
428
+
429
+ if state.freshness == GitFreshnessState.CLEAN:
430
+ return IncrementalSyncResult(
431
+ status="clean",
432
+ reindexed_files=[],
433
+ deleted_files=[],
434
+ new_commit=state.current_head,
435
+ duration_ms=(time.perf_counter() - start_time) * 1000.0,
436
+ detail="Repository index is already fresh.",
437
+ )
438
+
439
+ affected_modified: set[str] = set()
440
+ affected_deleted: set[str] = set()
441
+
442
+ # 1. Collect files changed between indexed_head and current_head
443
+ if state.indexed_head and state.current_head and state.indexed_head != state.current_head:
444
+ diff_raw = _run_git_cmd(repository, "diff", "--name-status", state.indexed_head, state.current_head)
445
+ if diff_raw:
446
+ for line in diff_raw.splitlines():
447
+ parts = line.strip().split(maxsplit=1)
448
+ if len(parts) == 2:
449
+ action, path_str = parts[0], parts[1]
450
+ if action.startswith("D"):
451
+ affected_deleted.add(path_str)
452
+ else:
453
+ affected_modified.add(path_str)
454
+
455
+ # 2. Add working tree changes
456
+ wt = state.working_tree
457
+ for p in wt.modified_files:
458
+ affected_modified.add(p)
459
+ for p in wt.staged_files:
460
+ affected_modified.add(p)
461
+ for p in wt.untracked_files:
462
+ affected_modified.add(p)
463
+ for p in wt.deleted_files:
464
+ affected_deleted.add(p)
465
+ for r in wt.renamed_files:
466
+ affected_deleted.add(r["old_path"])
467
+ affected_modified.add(r["new_path"])
468
+
469
+ # Don't try to index files that were deleted
470
+ affected_modified -= affected_deleted
471
+
472
+ if not affected_modified and not affected_deleted:
473
+ if not wt.is_dirty and state.current_head:
474
+ from codegraph.freshness import save_commit
475
+ save_commit(con, state.current_head, int(time.time()))
476
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
477
+ return IncrementalSyncResult(
478
+ status="clean",
479
+ reindexed_files=[],
480
+ deleted_files=[],
481
+ new_commit=state.current_head,
482
+ duration_ms=elapsed_ms,
483
+ detail="Repository index is already fresh.",
484
+ )
485
+
486
+ # Query known hashes to skip unchanged files
487
+ existing_hashes: dict[str, str] = {}
488
+ try:
489
+ cur = con.execute("SELECT path, hash FROM files")
490
+ for row in cur.fetchall():
491
+ existing_hashes[str(row[0])] = str(row[1])
492
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
493
+ pass
494
+
495
+ from codegraph.indexing.scanner import LANGUAGES
496
+
497
+ indexer = Indexer(repository, Settings(repository=repository))
498
+ reindexed: list[str] = []
499
+ removed: list[str] = []
500
+
501
+ # 3. Clean up deleted files from graph
502
+ for d_path in affected_deleted:
503
+ indexer._delete_file(con, d_path)
504
+ removed.append(d_path)
505
+
506
+ # 4. Incrementally index modified / added files
507
+ import hashlib
508
+ for m_path in affected_modified:
509
+ abs_p = repository / m_path
510
+ if abs_p.exists() and abs_p.is_file():
511
+ try:
512
+ # safe_path check
513
+ safe_path(repository, m_path)
514
+ ext = abs_p.suffix.lower()
515
+ lang = LANGUAGES.get(ext)
516
+ if not lang:
517
+ continue
518
+ content = abs_p.read_text(encoding="utf-8", errors="replace")
519
+ digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
520
+ if existing_hashes.get(m_path) == digest:
521
+ # Content is unchanged from current index; skip reindexing
522
+ continue
523
+ indexer._replace_file(con, m_path, lang, content, digest)
524
+ reindexed.append(m_path)
525
+ except (SecurityError, Exception):
526
+ continue
527
+
528
+ if reindexed or removed:
529
+ con.commit()
530
+
531
+ # 5. If working tree is clean, update indexed_commit
532
+ if not wt.is_dirty and state.current_head:
533
+ from codegraph.freshness import save_commit
534
+ save_commit(con, state.current_head, int(time.time()))
535
+
536
+ elapsed_ms = (time.perf_counter() - start_time) * 1000.0
537
+
538
+ return IncrementalSyncResult(
539
+ status="synced",
540
+ reindexed_files=sorted(reindexed),
541
+ deleted_files=sorted(removed),
542
+ new_commit=state.current_head,
543
+ duration_ms=elapsed_ms,
544
+ detail=f"Incrementally updated {len(reindexed)} files and removed {len(removed)} deleted files in {elapsed_ms:.1f}ms.",
545
+ )
@@ -0,0 +1,46 @@
1
+ from .models import GraphEdge, GraphSeedPolicy
2
+ from .traversal import (
3
+ analyze_impact,
4
+ definition_edges,
5
+ find_affected_tests,
6
+ find_callees,
7
+ find_callers,
8
+ find_implementations,
9
+ find_importers,
10
+ find_references,
11
+ find_related_tests,
12
+ find_symbol,
13
+ find_symbol_exact,
14
+ find_symbols,
15
+ get_call_graph,
16
+ get_callees,
17
+ get_callers,
18
+ get_dependency_graph,
19
+ get_symbol,
20
+ import_edges,
21
+ trace_call,
22
+ )
23
+
24
+ __all__ = [
25
+ "GraphEdge",
26
+ "analyze_impact",
27
+ "definition_edges",
28
+ "find_affected_tests",
29
+ "find_callees",
30
+ "find_callers",
31
+ "find_implementations",
32
+ "find_importers",
33
+ "find_references",
34
+ "find_related_tests",
35
+ "find_symbol",
36
+ "find_symbol_exact",
37
+ "find_symbols",
38
+ "get_call_graph",
39
+ "get_callees",
40
+ "get_callers",
41
+ "get_dependency_graph",
42
+ "get_symbol",
43
+ "import_edges",
44
+ "trace_call",
45
+ "GraphSeedPolicy",
46
+ ]
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from codegraph.epistemic import RelationshipEvidenceClass, validate_relationship_invariants
6
+ from codegraph.evidence_contract import validate_graph_edge_contract
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class GraphEdge:
11
+ source: str
12
+ target: str
13
+ relationship: str
14
+ confidence: str
15
+ file: str
16
+ start_line: int
17
+ end_line: int
18
+ evidence: str
19
+ evidence_class: str = RelationshipEvidenceClass.AST_VERIFIED.value
20
+ reason: str | None = None
21
+
22
+ def __post_init__(self) -> None:
23
+ validate_relationship_invariants(self.relationship, self.evidence_class, self.reason)
24
+ rel, ev, conf = validate_graph_edge_contract(
25
+ self.source, self.target, self.relationship, self.evidence_class, self.confidence
26
+ )
27
+ object.__setattr__(self, "relationship", rel)
28
+ object.__setattr__(self, "evidence_class", ev)
29
+ object.__setattr__(self, "confidence", conf)
30
+
31
+ def as_dict(self) -> dict[str, object]:
32
+ return asdict(self)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class GraphSeedPolicy:
37
+ """Policy for selecting grounded roots for graph traversal."""
38
+
39
+ source_type: str = "EXPLICIT_TARGET" # EXPLICIT_TARGET | ROUTE_HANDLER | DIRECT_MATCH | USER_REQUESTED
40
+ min_confidence: str = "HIGH" # HIGH | MEDIUM | LOW
41
+ max_seeds: int = 6
42
+ max_depth: int = 3
43
+ max_nodes_budget: int = 50
44
+ allowed_edges: frozenset[str] = frozenset({
45
+ "CALLS",
46
+ "CALLED_BY",
47
+ "HANDLED_BY",
48
+ "ROUTES_TO",
49
+ "MOUNTS",
50
+ "DEPENDS_ON",
51
+ "IMPORTS",
52
+ "TESTS",
53
+ "DEFINES",
54
+ "CONTAINS",
55
+ })
56
+