codegraph-engine 3.0.0__2-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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +868 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|