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
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Thread-safe SQLite connection pool for concurrent MCP and CLI execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import queue
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .classifier import make_compat_row
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SQLiteConnectionPool:
|
|
14
|
+
"""Thread-safe, re-entrant connection pool for SQLite databases.
|
|
15
|
+
|
|
16
|
+
Features:
|
|
17
|
+
- Thread-safe LIFO pool to maximize SQLite cache reuse.
|
|
18
|
+
- WAL mode, synchronous=NORMAL, foreign_keys=ON, busy_timeout=30000ms.
|
|
19
|
+
- check_same_thread=False for cross-thread pool handoff.
|
|
20
|
+
- Re-entrant per-thread acquisition (nesting depth tracking via thread-local state).
|
|
21
|
+
- Optional initializer hook for idempotent schema / setup execution.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
db_path: Path | str,
|
|
27
|
+
max_size: int = 16,
|
|
28
|
+
timeout: float = 30.0,
|
|
29
|
+
initializer: Callable[[sqlite3.Connection], None] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self.db_path = Path(db_path) if isinstance(db_path, str) else db_path
|
|
32
|
+
self.max_size = max(1, max_size)
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self.initializer = initializer
|
|
35
|
+
self._pool: queue.LifoQueue[sqlite3.Connection] = queue.LifoQueue()
|
|
36
|
+
self._created = 0
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._local = threading.local()
|
|
39
|
+
|
|
40
|
+
def _create_connection(self) -> sqlite3.Connection:
|
|
41
|
+
con = sqlite3.connect(
|
|
42
|
+
self.db_path,
|
|
43
|
+
check_same_thread=False,
|
|
44
|
+
timeout=self.timeout,
|
|
45
|
+
)
|
|
46
|
+
con.row_factory = make_compat_row
|
|
47
|
+
con.execute("PRAGMA journal_mode = WAL")
|
|
48
|
+
con.execute("PRAGMA synchronous = NORMAL")
|
|
49
|
+
con.execute("PRAGMA foreign_keys = ON")
|
|
50
|
+
con.execute("PRAGMA busy_timeout = 30000")
|
|
51
|
+
con.execute("PRAGMA cache_size = -64000")
|
|
52
|
+
con.execute("PRAGMA temp_store = MEMORY")
|
|
53
|
+
if self.initializer is not None:
|
|
54
|
+
self.initializer(con)
|
|
55
|
+
return con
|
|
56
|
+
|
|
57
|
+
def acquire(self) -> tuple[sqlite3.Connection, bool]:
|
|
58
|
+
"""Acquire a connection. Returns (connection, is_owner).
|
|
59
|
+
|
|
60
|
+
If the current thread already holds a connection from this pool,
|
|
61
|
+
it increments the re-entrancy depth and returns (connection, False).
|
|
62
|
+
"""
|
|
63
|
+
active_con = getattr(self._local, "con", None)
|
|
64
|
+
if active_con is not None:
|
|
65
|
+
self._local.nesting = getattr(self._local, "nesting", 0) + 1
|
|
66
|
+
return active_con, False
|
|
67
|
+
|
|
68
|
+
con: sqlite3.Connection | None = None
|
|
69
|
+
try:
|
|
70
|
+
con = self._pool.get_nowait()
|
|
71
|
+
except queue.Empty:
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._created < self.max_size:
|
|
74
|
+
con = self._create_connection()
|
|
75
|
+
self._created += 1
|
|
76
|
+
|
|
77
|
+
if con is None:
|
|
78
|
+
con = self._pool.get(timeout=self.timeout)
|
|
79
|
+
|
|
80
|
+
self._local.con = con
|
|
81
|
+
self._local.nesting = 1
|
|
82
|
+
return con, True
|
|
83
|
+
|
|
84
|
+
def release(
|
|
85
|
+
self,
|
|
86
|
+
con: sqlite3.Connection,
|
|
87
|
+
is_owner: bool,
|
|
88
|
+
rollback: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Release a connection back to the pool."""
|
|
91
|
+
if not is_owner:
|
|
92
|
+
cur_nesting = getattr(self._local, "nesting", 1) - 1
|
|
93
|
+
self._local.nesting = max(0, cur_nesting)
|
|
94
|
+
if rollback:
|
|
95
|
+
try:
|
|
96
|
+
con.rollback()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._local.con = None
|
|
102
|
+
self._local.nesting = 0
|
|
103
|
+
if rollback:
|
|
104
|
+
try:
|
|
105
|
+
con.rollback()
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
else:
|
|
109
|
+
try:
|
|
110
|
+
con.commit()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
self._pool.put_nowait(con)
|
|
116
|
+
except Exception:
|
|
117
|
+
try:
|
|
118
|
+
con.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
def close_all(self) -> None:
|
|
123
|
+
"""Close and dispose all connections in the pool."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
while True:
|
|
126
|
+
try:
|
|
127
|
+
con = self._pool.get_nowait()
|
|
128
|
+
try:
|
|
129
|
+
con.close()
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
except queue.Empty:
|
|
133
|
+
break
|
|
134
|
+
self._created = 0
|
|
135
|
+
self._local.con = None
|
|
136
|
+
self._local.nesting = 0
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Secure, deterministic repository scanner.
|
|
2
|
+
|
|
3
|
+
Architectural Invariants:
|
|
4
|
+
1. Low-level primitive `safe_path()` is strict and fail-closed (raises SecurityError on escape).
|
|
5
|
+
2. Repository scanner catches SecurityError on individual unsafe symlink/traversal entries,
|
|
6
|
+
records the security skip, and continues scanning without aborting the entire repository.
|
|
7
|
+
3. Unsafe/escaped paths are NEVER indexed or exposed.
|
|
8
|
+
4. Scan statistics are tracked explicitly.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from codegraph.errors import SecurityError
|
|
18
|
+
from codegraph.indexing.classifier import (
|
|
19
|
+
FileCategory,
|
|
20
|
+
classify_file,
|
|
21
|
+
is_ignored_by_codegraphignore,
|
|
22
|
+
load_codegraphignore,
|
|
23
|
+
)
|
|
24
|
+
from codegraph.security import is_sensitive, safe_path
|
|
25
|
+
|
|
26
|
+
IGNORED_DIRS = {
|
|
27
|
+
".git",
|
|
28
|
+
".venv",
|
|
29
|
+
"venv",
|
|
30
|
+
"node_modules",
|
|
31
|
+
"__pycache__",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
".mypy_cache",
|
|
35
|
+
".pytest_cache",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
LANGUAGES = {
|
|
39
|
+
".py": "python",
|
|
40
|
+
".js": "javascript",
|
|
41
|
+
".jsx": "javascript",
|
|
42
|
+
".ts": "typescript",
|
|
43
|
+
".tsx": "typescript",
|
|
44
|
+
".sql": "sql",
|
|
45
|
+
".prisma": "prisma",
|
|
46
|
+
".html": "html",
|
|
47
|
+
".htm": "html",
|
|
48
|
+
".jinja": "html",
|
|
49
|
+
".jinja2": "html",
|
|
50
|
+
".njk": "html",
|
|
51
|
+
".ejs": "html",
|
|
52
|
+
".lua": "luau",
|
|
53
|
+
".luau": "luau",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ScanResult:
|
|
59
|
+
path: Path
|
|
60
|
+
relative_path: Path
|
|
61
|
+
language: str
|
|
62
|
+
category: FileCategory = FileCategory.SOURCE
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ScanStats:
|
|
67
|
+
scanned: int = 0
|
|
68
|
+
accepted: int = 0
|
|
69
|
+
skipped: int = 0
|
|
70
|
+
skipped_security: int = 0
|
|
71
|
+
skipped_binary: int = 0
|
|
72
|
+
skipped_secret: int = 0
|
|
73
|
+
skipped_large: int = 0
|
|
74
|
+
skipped_ignored: int = 0
|
|
75
|
+
skipped_details: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
def as_dict(self) -> dict[str, object]:
|
|
78
|
+
return {
|
|
79
|
+
"scanned": self.scanned,
|
|
80
|
+
"accepted": self.accepted,
|
|
81
|
+
"skipped": self.skipped,
|
|
82
|
+
"skipped_security": self.skipped_security,
|
|
83
|
+
"skipped_binary": self.skipped_binary,
|
|
84
|
+
"skipped_secret": self.skipped_secret,
|
|
85
|
+
"skipped_large": self.skipped_large,
|
|
86
|
+
"skipped_ignored": self.skipped_ignored,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_binary(path: Path) -> bool:
|
|
91
|
+
try:
|
|
92
|
+
with path.open("rb") as fh:
|
|
93
|
+
return b"\x00" in fh.read(8192)
|
|
94
|
+
except OSError:
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def scan_with_stats(
|
|
99
|
+
repository: Path,
|
|
100
|
+
max_file_size: int,
|
|
101
|
+
exclusions: tuple[str, ...] = (),
|
|
102
|
+
) -> tuple[list[ScanResult], ScanStats]:
|
|
103
|
+
"""Scan repository for supported source files, catching unsafe symlinks and collecting stats."""
|
|
104
|
+
root = repository.resolve(strict=True)
|
|
105
|
+
gitignore = _gitignore_patterns(root)
|
|
106
|
+
codegraphignore = load_codegraphignore(root)
|
|
107
|
+
results: list[ScanResult] = []
|
|
108
|
+
stats = ScanStats()
|
|
109
|
+
|
|
110
|
+
for directory, dirs, files in os.walk(root, followlinks=False):
|
|
111
|
+
dirs[:] = [d for d in dirs if d not in IGNORED_DIRS]
|
|
112
|
+
for filename in files:
|
|
113
|
+
stats.scanned += 1
|
|
114
|
+
absolute = Path(directory) / filename
|
|
115
|
+
try:
|
|
116
|
+
rel_unresolved = absolute.relative_to(root)
|
|
117
|
+
except ValueError:
|
|
118
|
+
stats.skipped += 1
|
|
119
|
+
stats.skipped_security += 1
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
# safe_path raises SecurityError if symlink resolves outside root or uses traversal escapes
|
|
124
|
+
resolved = safe_path(root, rel_unresolved)
|
|
125
|
+
except SecurityError:
|
|
126
|
+
stats.skipped += 1
|
|
127
|
+
stats.skipped_security += 1
|
|
128
|
+
stats.skipped_details.append(f"Security blocked: {rel_unresolved}")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
relative = resolved.relative_to(root)
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
if not resolved.is_file():
|
|
135
|
+
stats.skipped += 1
|
|
136
|
+
continue
|
|
137
|
+
if resolved.stat().st_size > max_file_size:
|
|
138
|
+
stats.skipped += 1
|
|
139
|
+
stats.skipped_large += 1
|
|
140
|
+
continue
|
|
141
|
+
if is_sensitive(relative):
|
|
142
|
+
stats.skipped += 1
|
|
143
|
+
stats.skipped_secret += 1
|
|
144
|
+
continue
|
|
145
|
+
if (
|
|
146
|
+
_ignored_by_gitignore(relative, gitignore)
|
|
147
|
+
or is_ignored_by_codegraphignore(relative, codegraphignore)
|
|
148
|
+
or any(relative.match(p) for p in exclusions)
|
|
149
|
+
):
|
|
150
|
+
stats.skipped += 1
|
|
151
|
+
stats.skipped_ignored += 1
|
|
152
|
+
continue
|
|
153
|
+
if is_binary(resolved):
|
|
154
|
+
stats.skipped += 1
|
|
155
|
+
stats.skipped_binary += 1
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
category = classify_file(relative)
|
|
159
|
+
if category in (FileCategory.BUILD_ARTIFACT, FileCategory.VENDOR, FileCategory.BINARY):
|
|
160
|
+
stats.skipped += 1
|
|
161
|
+
if category == FileCategory.BINARY:
|
|
162
|
+
stats.skipped_binary += 1
|
|
163
|
+
else:
|
|
164
|
+
stats.skipped_ignored += 1
|
|
165
|
+
continue
|
|
166
|
+
except OSError:
|
|
167
|
+
stats.skipped += 1
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
language = LANGUAGES.get(relative.suffix.lower())
|
|
171
|
+
if language:
|
|
172
|
+
stats.accepted += 1
|
|
173
|
+
results.append(ScanResult(resolved, relative, language, category=category))
|
|
174
|
+
else:
|
|
175
|
+
stats.skipped += 1
|
|
176
|
+
|
|
177
|
+
results.sort(key=lambda item: item.relative_path.as_posix())
|
|
178
|
+
return results, stats
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def scan(
|
|
182
|
+
repository: Path,
|
|
183
|
+
max_file_size: int,
|
|
184
|
+
exclusions: tuple[str, ...] = (),
|
|
185
|
+
) -> list[ScanResult]:
|
|
186
|
+
"""Securely scan repository for supported source files."""
|
|
187
|
+
results, _ = scan_with_stats(repository, max_file_size, exclusions)
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _gitignore_patterns(root: Path) -> list[str]:
|
|
192
|
+
path = root / ".gitignore"
|
|
193
|
+
try:
|
|
194
|
+
return [
|
|
195
|
+
line.strip()
|
|
196
|
+
for line in path.read_text().splitlines()
|
|
197
|
+
if line.strip() and not line.lstrip().startswith("#") and not line.startswith("!")
|
|
198
|
+
]
|
|
199
|
+
except OSError:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _ignored_by_gitignore(relative: Path, patterns: list[str]) -> bool:
|
|
204
|
+
"""Small, deterministic subset: ordinary glob and directory patterns; negation is unsupported."""
|
|
205
|
+
value = relative.as_posix()
|
|
206
|
+
return any(
|
|
207
|
+
fnmatch.fnmatch(value, pattern.rstrip("/"))
|
|
208
|
+
or (pattern.endswith("/") and value.startswith(pattern))
|
|
209
|
+
or fnmatch.fnmatch(relative.name, pattern)
|
|
210
|
+
for pattern in patterns
|
|
211
|
+
)
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Deterministic 16-phase indexing telemetry, memory tracking, and progress formatting (v2.1.7)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
INDEXING_PHASES: tuple[tuple[str, str], ...] = (
|
|
13
|
+
("repository_scan", "Repository Scan"),
|
|
14
|
+
("file_classification", "File Classification"),
|
|
15
|
+
("file_reading", "File Reading"),
|
|
16
|
+
("ast_parsing", "AST Parsing"),
|
|
17
|
+
("symbol_extraction", "Symbol Extraction"),
|
|
18
|
+
("import_extraction", "Import Extraction"),
|
|
19
|
+
("framework_analysis", "Framework Analysis"),
|
|
20
|
+
("database_analysis", "Database Analysis"),
|
|
21
|
+
("graph_edge_creation", "Graph Edge Creation"),
|
|
22
|
+
("symbol_resolution", "Symbol Resolution"),
|
|
23
|
+
("relationship_resolution", "Relationship Resolution"),
|
|
24
|
+
("ranking_index_building", "Ranking/Index Building"),
|
|
25
|
+
("sqlite_writes", "SQLite Writes"),
|
|
26
|
+
("fts_updates", "FTS Updates"),
|
|
27
|
+
("post_processing", "Post-Processing"),
|
|
28
|
+
("final_commit_checkpoint", "Final Commit/Checkpoint"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
PHASE_LABELS: dict[str, str] = dict(INDEXING_PHASES)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_process_rss_mb() -> float:
|
|
35
|
+
"""Return current/peak RSS in megabytes safely across macOS, Linux, and Windows."""
|
|
36
|
+
try:
|
|
37
|
+
import resource as res_mod
|
|
38
|
+
|
|
39
|
+
if res_mod is None:
|
|
40
|
+
return 0.0
|
|
41
|
+
usage = res_mod.getrusage(res_mod.RUSAGE_SELF).ru_maxrss
|
|
42
|
+
if sys.platform == "darwin":
|
|
43
|
+
return round(usage / (1024.0 * 1024.0), 2)
|
|
44
|
+
return round(usage / 1024.0, 2)
|
|
45
|
+
except Exception:
|
|
46
|
+
return 0.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_duration_concise(seconds: float) -> str:
|
|
50
|
+
"""Format elapsed seconds as e.g. '4m 12s' or '1.42s'."""
|
|
51
|
+
if seconds >= 60.0:
|
|
52
|
+
mins = int(seconds // 60)
|
|
53
|
+
secs = int(round(seconds % 60))
|
|
54
|
+
return f"{mins}m {secs:02d}s"
|
|
55
|
+
return f"{seconds:.2f}s"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_progress_block(
|
|
59
|
+
phase: str,
|
|
60
|
+
completed_files: int,
|
|
61
|
+
total_files: int,
|
|
62
|
+
elapsed_seconds: float,
|
|
63
|
+
*,
|
|
64
|
+
memory_mb: float | None = None,
|
|
65
|
+
) -> str:
|
|
66
|
+
"""Render a concise multi-line progress block suitable for CLI display."""
|
|
67
|
+
label = PHASE_LABELS.get(phase, phase)
|
|
68
|
+
total = max(1, total_files)
|
|
69
|
+
pct = min(100, int(round((completed_files / total) * 100)))
|
|
70
|
+
rate_per_min = int(round((completed_files / max(0.001, elapsed_seconds)) * 60.0))
|
|
71
|
+
mem_val = int(round(memory_mb if memory_mb is not None else get_process_rss_mb()))
|
|
72
|
+
return (
|
|
73
|
+
f"Phase: {label}\n"
|
|
74
|
+
f"{completed_files:,} / {total_files:,} files\n"
|
|
75
|
+
f"{pct}%\n"
|
|
76
|
+
f"elapsed: {format_duration_concise(elapsed_seconds)}\n"
|
|
77
|
+
f"rate: {rate_per_min:,} files/min\n"
|
|
78
|
+
f"memory: {mem_val} MB"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class PhaseMetrics:
|
|
84
|
+
"""Telemetry counters and timing for a single indexing pipeline phase."""
|
|
85
|
+
|
|
86
|
+
phase: str
|
|
87
|
+
label: str
|
|
88
|
+
elapsed_seconds: float = 0.0
|
|
89
|
+
files_processed: int = 0
|
|
90
|
+
items_processed: int = 0
|
|
91
|
+
memory_mb: float = 0.0
|
|
92
|
+
errors: int = 0
|
|
93
|
+
skips: int = 0
|
|
94
|
+
db_writes: int = 0
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def errors_skips(self) -> int:
|
|
98
|
+
return self.errors + self.skips
|
|
99
|
+
|
|
100
|
+
@errors_skips.setter
|
|
101
|
+
def errors_skips(self, value: int) -> None:
|
|
102
|
+
self.skips = max(0, value - self.errors)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def throughput_per_sec(self) -> float:
|
|
106
|
+
if self.elapsed_seconds <= 0.0:
|
|
107
|
+
return 0.0
|
|
108
|
+
basis = self.items_processed if self.items_processed > 0 else self.files_processed
|
|
109
|
+
return round(basis / self.elapsed_seconds, 2)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def files_per_min(self) -> float:
|
|
113
|
+
if self.elapsed_seconds <= 0.0:
|
|
114
|
+
return 0.0
|
|
115
|
+
return round((self.files_processed / self.elapsed_seconds) * 60.0, 1)
|
|
116
|
+
|
|
117
|
+
def as_dict(self) -> dict[str, Any]:
|
|
118
|
+
return {
|
|
119
|
+
"phase": self.phase,
|
|
120
|
+
"label": self.label,
|
|
121
|
+
"elapsed_seconds": round(self.elapsed_seconds, 4),
|
|
122
|
+
"files_processed": self.files_processed,
|
|
123
|
+
"items_processed": self.items_processed,
|
|
124
|
+
"throughput_per_sec": self.throughput_per_sec,
|
|
125
|
+
"memory_mb": round(self.memory_mb, 2),
|
|
126
|
+
"errors": self.errors,
|
|
127
|
+
"skips": self.skips,
|
|
128
|
+
"errors_skips": self.errors + self.skips,
|
|
129
|
+
"db_writes": self.db_writes,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class IndexingTelemetry:
|
|
135
|
+
"""Complete 16-phase deterministic telemetry for an indexing pass."""
|
|
136
|
+
|
|
137
|
+
phases: dict[str, PhaseMetrics] = field(default_factory=dict)
|
|
138
|
+
started_at: float = field(default_factory=time.perf_counter)
|
|
139
|
+
total_elapsed_seconds: float = 0.0
|
|
140
|
+
scanned_files: int = 0
|
|
141
|
+
indexed_files: int = 0
|
|
142
|
+
unchanged_files: int = 0
|
|
143
|
+
removed_files: int = 0
|
|
144
|
+
parse_failed_files: int = 0
|
|
145
|
+
symbols_count: int = 0
|
|
146
|
+
edges_count: int = 0
|
|
147
|
+
rss_before_mb: float = 0.0
|
|
148
|
+
post_parse_rss_mb: float = 0.0
|
|
149
|
+
peak_rss_mb: float = 0.0
|
|
150
|
+
steady_state_rss_mb: float = 0.0
|
|
151
|
+
peak_wal_bytes: int = 0
|
|
152
|
+
final_wal_bytes: int = 0
|
|
153
|
+
final_db_bytes: int = 0
|
|
154
|
+
commit_batches: int = 0
|
|
155
|
+
checkpoints: int = 0
|
|
156
|
+
interrupted: bool = False
|
|
157
|
+
interrupted_phase: str | None = None
|
|
158
|
+
warnings: list[str] = field(default_factory=list)
|
|
159
|
+
|
|
160
|
+
def __post_init__(self) -> None:
|
|
161
|
+
rss = get_process_rss_mb()
|
|
162
|
+
if self.rss_before_mb == 0.0:
|
|
163
|
+
self.rss_before_mb = rss
|
|
164
|
+
if self.peak_rss_mb == 0.0:
|
|
165
|
+
self.peak_rss_mb = rss
|
|
166
|
+
for key, label in INDEXING_PHASES:
|
|
167
|
+
if key not in self.phases:
|
|
168
|
+
self.phases[key] = PhaseMetrics(phase=key, label=label)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def batch_commits(self) -> int:
|
|
172
|
+
return self.commit_batches
|
|
173
|
+
|
|
174
|
+
@batch_commits.setter
|
|
175
|
+
def batch_commits(self, value: int) -> None:
|
|
176
|
+
self.commit_batches = value
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def wal_checkpoints(self) -> int:
|
|
180
|
+
return self.checkpoints
|
|
181
|
+
|
|
182
|
+
@wal_checkpoints.setter
|
|
183
|
+
def wal_checkpoints(self, value: int) -> None:
|
|
184
|
+
self.checkpoints = value
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def max_wal_size_mb(self) -> float:
|
|
188
|
+
return round(self.peak_wal_bytes / (1024.0 * 1024.0), 3)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def final_wal_size_mb(self) -> float:
|
|
192
|
+
return round(self.final_wal_bytes / (1024.0 * 1024.0), 3)
|
|
193
|
+
|
|
194
|
+
@contextmanager
|
|
195
|
+
def phase(self, name: str) -> Iterator[PhaseMetrics]:
|
|
196
|
+
"""Time a phase and update its memory snapshot upon exit."""
|
|
197
|
+
if name not in self.phases:
|
|
198
|
+
self.phases[name] = PhaseMetrics(phase=name, label=PHASE_LABELS.get(name, name))
|
|
199
|
+
metrics = self.phases[name]
|
|
200
|
+
t0 = time.perf_counter()
|
|
201
|
+
try:
|
|
202
|
+
yield metrics
|
|
203
|
+
except BaseException:
|
|
204
|
+
if self.interrupted_phase is None:
|
|
205
|
+
self.interrupted_phase = name
|
|
206
|
+
raise
|
|
207
|
+
finally:
|
|
208
|
+
metrics.elapsed_seconds += time.perf_counter() - t0
|
|
209
|
+
rss = get_process_rss_mb()
|
|
210
|
+
metrics.memory_mb = max(metrics.memory_mb, rss)
|
|
211
|
+
if rss > self.peak_rss_mb:
|
|
212
|
+
self.peak_rss_mb = rss
|
|
213
|
+
|
|
214
|
+
def record(
|
|
215
|
+
self,
|
|
216
|
+
phase: str,
|
|
217
|
+
elapsed: float = 0.0,
|
|
218
|
+
files: int = 0,
|
|
219
|
+
items: int = 0,
|
|
220
|
+
errors: int = 0,
|
|
221
|
+
skips: int = 0,
|
|
222
|
+
db_writes: int = 0,
|
|
223
|
+
update_memory: bool = False,
|
|
224
|
+
*,
|
|
225
|
+
files_processed: int = 0,
|
|
226
|
+
items_processed: int = 0,
|
|
227
|
+
errors_skips: int = 0,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Accumulate metrics for a given phase."""
|
|
230
|
+
if phase not in self.phases:
|
|
231
|
+
self.phases[phase] = PhaseMetrics(phase=phase, label=PHASE_LABELS.get(phase, phase))
|
|
232
|
+
m = self.phases[phase]
|
|
233
|
+
m.elapsed_seconds += elapsed
|
|
234
|
+
m.files_processed += files + files_processed
|
|
235
|
+
m.items_processed += items + items_processed
|
|
236
|
+
m.errors += errors
|
|
237
|
+
m.skips += skips + errors_skips
|
|
238
|
+
m.db_writes += db_writes
|
|
239
|
+
if update_memory or m.memory_mb == 0.0:
|
|
240
|
+
rss = get_process_rss_mb()
|
|
241
|
+
m.memory_mb = max(m.memory_mb, rss)
|
|
242
|
+
if rss > self.peak_rss_mb:
|
|
243
|
+
self.peak_rss_mb = rss
|
|
244
|
+
|
|
245
|
+
def observe_wal(self, db_path: Path) -> None:
|
|
246
|
+
"""Sample the SQLite WAL file size and update peak_wal_bytes."""
|
|
247
|
+
wal_path = Path(f"{db_path}-wal")
|
|
248
|
+
try:
|
|
249
|
+
if wal_path.exists():
|
|
250
|
+
sz = wal_path.stat().st_size
|
|
251
|
+
if sz > self.peak_wal_bytes:
|
|
252
|
+
self.peak_wal_bytes = sz
|
|
253
|
+
self.final_wal_bytes = sz
|
|
254
|
+
else:
|
|
255
|
+
self.final_wal_bytes = 0
|
|
256
|
+
except OSError:
|
|
257
|
+
pass
|
|
258
|
+
|
|
259
|
+
def finish(self, db_path: Path | None = None) -> None:
|
|
260
|
+
"""Finalize total elapsed time, steady-state RSS, and final DB/WAL sizes."""
|
|
261
|
+
self.total_elapsed_seconds = time.perf_counter() - self.started_at
|
|
262
|
+
rss = get_process_rss_mb()
|
|
263
|
+
self.steady_state_rss_mb = rss
|
|
264
|
+
if rss > self.peak_rss_mb:
|
|
265
|
+
self.peak_rss_mb = rss
|
|
266
|
+
if db_path is not None:
|
|
267
|
+
self.observe_wal(db_path)
|
|
268
|
+
try:
|
|
269
|
+
if db_path.exists():
|
|
270
|
+
self.final_db_bytes = db_path.stat().st_size
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def slowest_phase(self) -> str:
|
|
276
|
+
candidates = [
|
|
277
|
+
k for k in self.phases if k != "post_processing" and self.phases[k].elapsed_seconds > 0
|
|
278
|
+
]
|
|
279
|
+
if not candidates:
|
|
280
|
+
return "repository_scan"
|
|
281
|
+
return max(candidates, key=lambda k: self.phases[k].elapsed_seconds)
|
|
282
|
+
|
|
283
|
+
def format_progress_block(
|
|
284
|
+
self,
|
|
285
|
+
phase: str,
|
|
286
|
+
completed_files: int,
|
|
287
|
+
total_files: int,
|
|
288
|
+
elapsed_seconds: float,
|
|
289
|
+
) -> str:
|
|
290
|
+
"""Render a concise multi-line progress block suitable for CLI display."""
|
|
291
|
+
return format_progress_block(
|
|
292
|
+
phase,
|
|
293
|
+
completed_files,
|
|
294
|
+
total_files,
|
|
295
|
+
elapsed_seconds,
|
|
296
|
+
memory_mb=max(self.peak_rss_mb, get_process_rss_mb()),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def as_dict(self) -> dict[str, Any]:
|
|
300
|
+
return {
|
|
301
|
+
"total_elapsed_seconds": round(self.total_elapsed_seconds, 4),
|
|
302
|
+
"scanned_files": self.scanned_files,
|
|
303
|
+
"indexed_files": self.indexed_files,
|
|
304
|
+
"unchanged_files": self.unchanged_files,
|
|
305
|
+
"removed_files": self.removed_files,
|
|
306
|
+
"parse_failed_files": self.parse_failed_files,
|
|
307
|
+
"symbols_count": self.symbols_count,
|
|
308
|
+
"edges_count": self.edges_count,
|
|
309
|
+
"files_per_sec": round(self.scanned_files / max(0.0001, self.total_elapsed_seconds), 2),
|
|
310
|
+
"symbols_per_sec": round(self.symbols_count / max(0.0001, self.total_elapsed_seconds), 2),
|
|
311
|
+
"edges_per_sec": round(self.edges_count / max(0.0001, self.total_elapsed_seconds), 2),
|
|
312
|
+
"rss_before_mb": round(self.rss_before_mb, 2),
|
|
313
|
+
"post_parse_rss_mb": round(self.post_parse_rss_mb, 2),
|
|
314
|
+
"peak_rss_mb": round(self.peak_rss_mb, 2),
|
|
315
|
+
"steady_state_rss_mb": round(self.steady_state_rss_mb, 2),
|
|
316
|
+
"peak_wal_mb": round(self.peak_wal_bytes / (1024.0 * 1024.0), 3),
|
|
317
|
+
"final_wal_mb": round(self.final_wal_bytes / (1024.0 * 1024.0), 3),
|
|
318
|
+
"final_db_mb": round(self.final_db_bytes / (1024.0 * 1024.0), 3),
|
|
319
|
+
"commit_batches": self.commit_batches,
|
|
320
|
+
"checkpoints": self.checkpoints,
|
|
321
|
+
"slowest_phase": self.slowest_phase,
|
|
322
|
+
"interrupted": self.interrupted,
|
|
323
|
+
"interrupted_phase": self.interrupted_phase,
|
|
324
|
+
"warnings": list(self.warnings),
|
|
325
|
+
"phases": {k: self.phases[k].as_dict() for k, _ in INDEXING_PHASES},
|
|
326
|
+
}
|