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
@@ -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
+ }