superlocalmemory 3.3.20 → 3.3.21

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 (78) hide show
  1. package/package.json +1 -1
  2. package/pyproject.toml +9 -1
  3. package/src/superlocalmemory/cli/commands.py +138 -22
  4. package/src/superlocalmemory/cli/daemon.py +372 -0
  5. package/src/superlocalmemory/cli/main.py +8 -0
  6. package/src/superlocalmemory/cli/pending_store.py +158 -0
  7. package/src/superlocalmemory/cli/setup_wizard.py +39 -6
  8. package/src/superlocalmemory/code_graph/__init__.py +46 -0
  9. package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
  10. package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
  11. package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
  12. package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
  13. package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
  14. package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
  15. package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
  16. package/src/superlocalmemory/code_graph/changes.py +363 -0
  17. package/src/superlocalmemory/code_graph/communities.py +299 -0
  18. package/src/superlocalmemory/code_graph/config.py +88 -0
  19. package/src/superlocalmemory/code_graph/database.py +482 -0
  20. package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
  21. package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
  22. package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
  23. package/src/superlocalmemory/code_graph/flows.py +350 -0
  24. package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
  25. package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
  26. package/src/superlocalmemory/code_graph/graph_store.py +158 -0
  27. package/src/superlocalmemory/code_graph/incremental.py +200 -0
  28. package/src/superlocalmemory/code_graph/models.py +130 -0
  29. package/src/superlocalmemory/code_graph/parser.py +507 -0
  30. package/src/superlocalmemory/code_graph/resolver.py +321 -0
  31. package/src/superlocalmemory/code_graph/search.py +460 -0
  32. package/src/superlocalmemory/code_graph/service.py +95 -0
  33. package/src/superlocalmemory/code_graph/watcher.py +207 -0
  34. package/src/superlocalmemory/core/embedding_worker.py +4 -2
  35. package/src/superlocalmemory/core/embeddings.py +8 -2
  36. package/src/superlocalmemory/core/engine.py +32 -0
  37. package/src/superlocalmemory/core/engine_wiring.py +5 -0
  38. package/src/superlocalmemory/core/store_pipeline.py +23 -1
  39. package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
  40. package/src/superlocalmemory/infra/event_bus.py +5 -0
  41. package/src/superlocalmemory/mcp/server.py +23 -0
  42. package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
  43. package/src/superlocalmemory/retrieval/engine.py +137 -2
  44. package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
  45. package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
  46. package/src/superlocalmemory/retrieval/strategy.py +16 -0
  47. package/src/superlocalmemory/server/api.py +4 -2
  48. package/src/superlocalmemory/server/ui.py +5 -2
  49. package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
  50. package/src/superlocalmemory/ui/index.html +1879 -0
  51. package/src/superlocalmemory/ui/js/agents.js +192 -0
  52. package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
  53. package/src/superlocalmemory/ui/js/behavioral.js +276 -0
  54. package/src/superlocalmemory/ui/js/clusters.js +206 -0
  55. package/src/superlocalmemory/ui/js/compliance.js +252 -0
  56. package/src/superlocalmemory/ui/js/core.js +246 -0
  57. package/src/superlocalmemory/ui/js/dashboard.js +110 -0
  58. package/src/superlocalmemory/ui/js/events.js +178 -0
  59. package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
  60. package/src/superlocalmemory/ui/js/feedback.js +333 -0
  61. package/src/superlocalmemory/ui/js/graph-core.js +447 -0
  62. package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
  63. package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
  64. package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
  65. package/src/superlocalmemory/ui/js/ide-status.js +102 -0
  66. package/src/superlocalmemory/ui/js/init.js +45 -0
  67. package/src/superlocalmemory/ui/js/learning.js +435 -0
  68. package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
  69. package/src/superlocalmemory/ui/js/math-health.js +98 -0
  70. package/src/superlocalmemory/ui/js/memories.js +264 -0
  71. package/src/superlocalmemory/ui/js/modal.js +357 -0
  72. package/src/superlocalmemory/ui/js/patterns.js +93 -0
  73. package/src/superlocalmemory/ui/js/profiles.js +236 -0
  74. package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
  75. package/src/superlocalmemory/ui/js/search.js +59 -0
  76. package/src/superlocalmemory/ui/js/settings.js +224 -0
  77. package/src/superlocalmemory/ui/js/timeline.js +32 -0
  78. package/src/superlocalmemory/ui/js/trust-dashboard.js +73 -0
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4 — CodeGraph Module
4
+
5
+ """Configuration for the CodeGraph module.
6
+
7
+ Frozen dataclass with all tunables. Sensible defaults for typical repos.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+
16
+ # Languages supported out of the box (tree-sitter grammar names)
17
+ DEFAULT_LANGUAGES: frozenset[str] = frozenset({
18
+ "python", "typescript", "tsx", "javascript", "jsx",
19
+ })
20
+
21
+ # File extensions → language mapping
22
+ DEFAULT_EXTENSION_MAP: dict[str, str] = {
23
+ ".py": "python",
24
+ ".ts": "typescript",
25
+ ".tsx": "tsx",
26
+ ".js": "javascript",
27
+ ".jsx": "jsx",
28
+ }
29
+
30
+ # Directories to always skip
31
+ DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset({
32
+ "node_modules", ".git", "__pycache__", ".venv", "venv",
33
+ ".tox", ".mypy_cache", ".pytest_cache", "dist", "build",
34
+ ".next", ".nuxt", "coverage", ".code-review-graph",
35
+ "vendor", ".eggs", "*.egg-info",
36
+ })
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CodeGraphConfig:
41
+ """Configuration for the CodeGraph module.
42
+
43
+ All paths are absolute. File paths stored in the DB are relative
44
+ to repo_root (HR-02).
45
+ """
46
+
47
+ # --- Core ---
48
+ enabled: bool = False # Feature flag (default off for backward compat)
49
+ repo_root: Path = field(default_factory=lambda: Path.cwd())
50
+ db_path: Path | None = None # If None, derived from SLM base_dir
51
+
52
+ # --- Parser ---
53
+ languages: frozenset[str] = DEFAULT_LANGUAGES
54
+ extension_map: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_EXTENSION_MAP))
55
+ exclude_dirs: frozenset[str] = DEFAULT_EXCLUDE_DIRS
56
+ exclude_patterns: tuple[str, ...] = () # Additional glob patterns to skip
57
+ max_file_size_bytes: int = 1_000_000 # Skip files > 1MB
58
+ parse_timeout_seconds: float = 30.0 # Per-file parse timeout
59
+ parallel_workers: int = 4 # ProcessPoolExecutor workers
60
+
61
+ # --- Graph ---
62
+ batch_size: int = 450 # SQLite variable limit workaround
63
+ max_depth_blast_radius: int = 2 # BFS depth for impact analysis
64
+ max_nodes_blast_radius: int = 500 # Cap on blast radius nodes
65
+
66
+ # --- Search ---
67
+ rrf_k: int = 60 # RRF fusion constant
68
+ search_limit: int = 20 # Default search result limit
69
+
70
+ # --- Resolution ---
71
+ heuristic_confidence: float = 0.6 # Confidence for heuristic name matches
72
+
73
+ # --- Bridge ---
74
+ bridge_enabled: bool = False # Bridge feature flag (default off)
75
+
76
+ # --- Watch ---
77
+ watch_debounce_ms: int = 300 # Debounce interval for file watcher
78
+
79
+ def get_db_path(self, slm_base_dir: Path | None = None) -> Path:
80
+ """Resolve the database path.
81
+
82
+ Priority: explicit db_path > slm_base_dir/code_graph.db > ~/.superlocalmemory/code_graph.db
83
+ """
84
+ if self.db_path is not None:
85
+ return self.db_path
86
+ if slm_base_dir is not None:
87
+ return slm_base_dir / "code_graph.db"
88
+ return Path.home() / ".superlocalmemory" / "code_graph.db"
@@ -0,0 +1,482 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4 — CodeGraph Module
4
+
5
+ """Database manager for code_graph.db.
6
+
7
+ Mirrors SLM's DatabaseManager pattern: per-call connections, WAL mode,
8
+ retry with backoff, transaction support. Points at a separate SQLite file.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import sqlite3
16
+ import threading
17
+ import time
18
+ from contextlib import contextmanager
19
+ from pathlib import Path
20
+ from typing import Any, Generator
21
+
22
+ from superlocalmemory.code_graph.models import (
23
+ CodeMemoryLink,
24
+ EdgeKind,
25
+ FileRecord,
26
+ GraphEdge,
27
+ GraphNode,
28
+ LinkType,
29
+ NodeKind,
30
+ )
31
+ from superlocalmemory.storage import schema_code_graph
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _BUSY_TIMEOUT_MS = 10_000
36
+ _MAX_RETRIES = 5
37
+ _RETRY_BASE_SECONDS = 0.1
38
+
39
+
40
+ class CodeGraphDatabase:
41
+ """SQLite database manager for code_graph.db.
42
+
43
+ Each public method opens a fresh connection, executes, and closes.
44
+ WAL mode enabled. Foreign keys enforced. Retry with backoff on SQLITE_BUSY.
45
+ """
46
+
47
+ def __init__(self, db_path: Path) -> None:
48
+ self.db_path = Path(db_path)
49
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
50
+ self._lock = threading.Lock()
51
+ self._txn_conn: sqlite3.Connection | None = None
52
+ self._version = 0 # Incremented on every write (cache invalidation)
53
+
54
+ # Initialize: create file, enable WAL, create schema
55
+ conn = self._connect()
56
+ try:
57
+ schema_code_graph.create_all_tables(conn)
58
+ finally:
59
+ conn.close()
60
+ logger.info("CodeGraphDatabase initialized at %s", self.db_path)
61
+
62
+ @property
63
+ def version(self) -> int:
64
+ """Monotonic version counter. Incremented on writes."""
65
+ return self._version
66
+
67
+ def _connect(self) -> sqlite3.Connection:
68
+ conn = sqlite3.connect(str(self.db_path), timeout=_BUSY_TIMEOUT_MS / 1000)
69
+ conn.row_factory = sqlite3.Row
70
+ conn.execute("PRAGMA journal_mode=WAL")
71
+ conn.execute("PRAGMA foreign_keys=ON")
72
+ conn.execute("PRAGMA synchronous=NORMAL")
73
+ return conn
74
+
75
+ def execute(
76
+ self, sql: str, params: tuple[Any, ...] = ()
77
+ ) -> list[sqlite3.Row]:
78
+ """Execute SQL with retry. Returns list of rows."""
79
+ # If inside a transaction, use the shared connection
80
+ if self._txn_conn is not None:
81
+ cursor = self._txn_conn.execute(sql, params)
82
+ return cursor.fetchall()
83
+
84
+ for attempt in range(_MAX_RETRIES):
85
+ conn = self._connect()
86
+ try:
87
+ cursor = conn.execute(sql, params)
88
+ rows = cursor.fetchall()
89
+ conn.commit()
90
+ return rows
91
+ except sqlite3.OperationalError as exc:
92
+ if "locked" in str(exc) and attempt < _MAX_RETRIES - 1:
93
+ wait = _RETRY_BASE_SECONDS * (2 ** attempt)
94
+ logger.debug("DB locked, retry %d in %.1fs", attempt + 1, wait)
95
+ time.sleep(wait)
96
+ else:
97
+ raise
98
+ finally:
99
+ conn.close()
100
+ return [] # Unreachable, but satisfies type checker
101
+
102
+ def execute_write(
103
+ self, sql: str, params: tuple[Any, ...] = ()
104
+ ) -> int:
105
+ """Execute a write statement. Returns rowcount. Bumps version."""
106
+ if self._txn_conn is not None:
107
+ cursor = self._txn_conn.execute(sql, params)
108
+ self._version += 1
109
+ return cursor.rowcount
110
+
111
+ conn = self._connect()
112
+ try:
113
+ cursor = conn.execute(sql, params)
114
+ conn.commit()
115
+ self._version += 1
116
+ return cursor.rowcount
117
+ finally:
118
+ conn.close()
119
+
120
+ @contextmanager
121
+ def transaction(self) -> Generator[None, None, None]:
122
+ """Context manager for multi-statement transactions."""
123
+ with self._lock:
124
+ conn = self._connect()
125
+ conn.execute("BEGIN IMMEDIATE")
126
+ self._txn_conn = conn
127
+ try:
128
+ yield
129
+ conn.commit()
130
+ self._version += 1
131
+ except Exception:
132
+ conn.rollback()
133
+ raise
134
+ finally:
135
+ self._txn_conn = None
136
+ conn.close()
137
+
138
+ # ------------------------------------------------------------------
139
+ # Node CRUD
140
+ # ------------------------------------------------------------------
141
+
142
+ def upsert_node(self, node: GraphNode) -> None:
143
+ """Insert or replace a graph node."""
144
+ self.execute_write(
145
+ """INSERT OR REPLACE INTO graph_nodes
146
+ (node_id, kind, name, qualified_name, file_path,
147
+ line_start, line_end, language, parent_name, signature,
148
+ docstring, is_test, content_hash, community_id,
149
+ extra_json, created_at, updated_at)
150
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
151
+ (
152
+ node.node_id, node.kind.value, node.name, node.qualified_name,
153
+ node.file_path, node.line_start, node.line_end, node.language,
154
+ node.parent_name, node.signature, node.docstring,
155
+ int(node.is_test), node.content_hash, node.community_id,
156
+ node.extra_json, node.created_at, node.updated_at,
157
+ ),
158
+ )
159
+
160
+ def get_node(self, node_id: str) -> GraphNode | None:
161
+ """Retrieve a single node by ID."""
162
+ rows = self.execute(
163
+ "SELECT * FROM graph_nodes WHERE node_id = ?", (node_id,)
164
+ )
165
+ return self._row_to_node(rows[0]) if rows else None
166
+
167
+ def get_node_by_qualified_name(self, qualified_name: str) -> GraphNode | None:
168
+ """Retrieve a node by its unique qualified name."""
169
+ rows = self.execute(
170
+ "SELECT * FROM graph_nodes WHERE qualified_name = ?",
171
+ (qualified_name,),
172
+ )
173
+ return self._row_to_node(rows[0]) if rows else None
174
+
175
+ def get_nodes_by_file(self, file_path: str) -> list[GraphNode]:
176
+ """All nodes in a file, ordered by line_start."""
177
+ rows = self.execute(
178
+ "SELECT * FROM graph_nodes WHERE file_path = ? ORDER BY line_start",
179
+ (file_path,),
180
+ )
181
+ return [self._row_to_node(r) for r in rows]
182
+
183
+ def get_all_nodes(self) -> list[GraphNode]:
184
+ """All nodes in the graph."""
185
+ rows = self.execute("SELECT * FROM graph_nodes")
186
+ return [self._row_to_node(r) for r in rows]
187
+
188
+ def get_node_count(self) -> int:
189
+ """Total node count."""
190
+ rows = self.execute("SELECT COUNT(*) as cnt FROM graph_nodes")
191
+ return rows[0]["cnt"] if rows else 0
192
+
193
+ def delete_nodes_by_file(self, file_path: str) -> int:
194
+ """Delete all nodes for a file. Cascades to edges via FK."""
195
+ return self.execute_write(
196
+ "DELETE FROM graph_nodes WHERE file_path = ?", (file_path,)
197
+ )
198
+
199
+ # ------------------------------------------------------------------
200
+ # Edge CRUD
201
+ # ------------------------------------------------------------------
202
+
203
+ def upsert_edge(self, edge: GraphEdge) -> None:
204
+ """Insert or replace a graph edge."""
205
+ self.execute_write(
206
+ """INSERT OR REPLACE INTO graph_edges
207
+ (edge_id, kind, source_node_id, target_node_id, file_path,
208
+ line, confidence, extra_json, created_at, updated_at)
209
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
210
+ (
211
+ edge.edge_id, edge.kind.value, edge.source_node_id,
212
+ edge.target_node_id, edge.file_path, edge.line,
213
+ edge.confidence, edge.extra_json,
214
+ edge.created_at, edge.updated_at,
215
+ ),
216
+ )
217
+
218
+ def get_edges_from(
219
+ self, node_id: str, kind: EdgeKind | None = None
220
+ ) -> list[GraphEdge]:
221
+ """Outgoing edges from a node."""
222
+ if kind is not None:
223
+ rows = self.execute(
224
+ "SELECT * FROM graph_edges WHERE source_node_id = ? AND kind = ?",
225
+ (node_id, kind.value),
226
+ )
227
+ else:
228
+ rows = self.execute(
229
+ "SELECT * FROM graph_edges WHERE source_node_id = ?",
230
+ (node_id,),
231
+ )
232
+ return [self._row_to_edge(r) for r in rows]
233
+
234
+ def get_edges_to(
235
+ self, node_id: str, kind: EdgeKind | None = None
236
+ ) -> list[GraphEdge]:
237
+ """Incoming edges to a node."""
238
+ if kind is not None:
239
+ rows = self.execute(
240
+ "SELECT * FROM graph_edges WHERE target_node_id = ? AND kind = ?",
241
+ (node_id, kind.value),
242
+ )
243
+ else:
244
+ rows = self.execute(
245
+ "SELECT * FROM graph_edges WHERE target_node_id = ?",
246
+ (node_id,),
247
+ )
248
+ return [self._row_to_edge(r) for r in rows]
249
+
250
+ def get_all_edges(self) -> list[GraphEdge]:
251
+ """All edges in the graph."""
252
+ rows = self.execute("SELECT * FROM graph_edges")
253
+ return [self._row_to_edge(r) for r in rows]
254
+
255
+ def get_edge_count(self) -> int:
256
+ """Total edge count."""
257
+ rows = self.execute("SELECT COUNT(*) as cnt FROM graph_edges")
258
+ return rows[0]["cnt"] if rows else 0
259
+
260
+ def delete_edges_by_file(self, file_path: str) -> int:
261
+ """Delete all edges originating from a file."""
262
+ return self.execute_write(
263
+ "DELETE FROM graph_edges WHERE file_path = ?", (file_path,)
264
+ )
265
+
266
+ # ------------------------------------------------------------------
267
+ # File record CRUD
268
+ # ------------------------------------------------------------------
269
+
270
+ def upsert_file_record(self, record: FileRecord) -> None:
271
+ """Insert or replace a file tracking record."""
272
+ self.execute_write(
273
+ """INSERT OR REPLACE INTO graph_files
274
+ (file_path, content_hash, mtime, language,
275
+ node_count, edge_count, last_indexed)
276
+ VALUES (?, ?, ?, ?, ?, ?, ?)""",
277
+ (
278
+ record.file_path, record.content_hash, record.mtime,
279
+ record.language, record.node_count, record.edge_count,
280
+ record.last_indexed,
281
+ ),
282
+ )
283
+
284
+ def get_file_record(self, file_path: str) -> FileRecord | None:
285
+ """Retrieve file record."""
286
+ rows = self.execute(
287
+ "SELECT * FROM graph_files WHERE file_path = ?", (file_path,)
288
+ )
289
+ if not rows:
290
+ return None
291
+ r = rows[0]
292
+ return FileRecord(
293
+ file_path=r["file_path"],
294
+ content_hash=r["content_hash"],
295
+ mtime=r["mtime"],
296
+ language=r["language"],
297
+ node_count=r["node_count"],
298
+ edge_count=r["edge_count"],
299
+ last_indexed=r["last_indexed"],
300
+ )
301
+
302
+ def get_all_file_records(self) -> list[FileRecord]:
303
+ """All tracked files."""
304
+ rows = self.execute("SELECT * FROM graph_files")
305
+ return [
306
+ FileRecord(
307
+ file_path=r["file_path"],
308
+ content_hash=r["content_hash"],
309
+ mtime=r["mtime"],
310
+ language=r["language"],
311
+ node_count=r["node_count"],
312
+ edge_count=r["edge_count"],
313
+ last_indexed=r["last_indexed"],
314
+ )
315
+ for r in rows
316
+ ]
317
+
318
+ def delete_file_record(self, file_path: str) -> None:
319
+ """Delete a file record."""
320
+ self.execute_write(
321
+ "DELETE FROM graph_files WHERE file_path = ?", (file_path,)
322
+ )
323
+
324
+ # ------------------------------------------------------------------
325
+ # Metadata
326
+ # ------------------------------------------------------------------
327
+
328
+ def get_metadata(self, key: str) -> str | None:
329
+ """Read a metadata value."""
330
+ rows = self.execute(
331
+ "SELECT value FROM graph_metadata WHERE key = ?", (key,)
332
+ )
333
+ return rows[0]["value"] if rows else None
334
+
335
+ def set_metadata(self, key: str, value: str) -> None:
336
+ """Write a metadata value (upsert)."""
337
+ self.execute_write(
338
+ """INSERT OR REPLACE INTO graph_metadata (key, value, updated_at)
339
+ VALUES (?, ?, ?)""",
340
+ (key, value, time.time()),
341
+ )
342
+
343
+ # ------------------------------------------------------------------
344
+ # Atomic file replacement
345
+ # ------------------------------------------------------------------
346
+
347
+ def store_file_parse_results(
348
+ self,
349
+ file_path: str,
350
+ nodes: list[GraphNode],
351
+ edges: list[GraphEdge],
352
+ file_record: FileRecord,
353
+ ) -> None:
354
+ """Atomically replace all data for a file.
355
+
356
+ Within a single transaction:
357
+ 1. Delete old edges for this file
358
+ 2. Delete old nodes for this file (cascades code_memory_links)
359
+ 3. Insert new nodes
360
+ 4. Insert new edges
361
+ 5. Upsert file record
362
+ """
363
+ with self.transaction():
364
+ # Delete old data (edges first due to FK)
365
+ self.execute_write(
366
+ "DELETE FROM graph_edges WHERE file_path = ?", (file_path,)
367
+ )
368
+ self.execute_write(
369
+ "DELETE FROM graph_nodes WHERE file_path = ?", (file_path,)
370
+ )
371
+ # Insert new nodes
372
+ for node in nodes:
373
+ self.upsert_node(node)
374
+ # Insert new edges
375
+ for edge in edges:
376
+ self.upsert_edge(edge)
377
+ # Upsert file record
378
+ self.upsert_file_record(file_record)
379
+
380
+ # ------------------------------------------------------------------
381
+ # Stats
382
+ # ------------------------------------------------------------------
383
+
384
+ def get_stats(self) -> dict[str, int]:
385
+ """Returns {"nodes": N, "edges": E, "files": F}."""
386
+ return {
387
+ "nodes": self.get_node_count(),
388
+ "edges": self.get_edge_count(),
389
+ "files": len(self.get_all_file_records()),
390
+ }
391
+
392
+ # ------------------------------------------------------------------
393
+ # Code memory links (bridge)
394
+ # ------------------------------------------------------------------
395
+
396
+ def upsert_link(self, link: CodeMemoryLink) -> None:
397
+ """Insert or replace a code-memory bridge link."""
398
+ self.execute_write(
399
+ """INSERT OR REPLACE INTO code_memory_links
400
+ (link_id, code_node_id, slm_fact_id, slm_entity_id,
401
+ link_type, confidence, created_at, last_verified, is_stale)
402
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
403
+ (
404
+ link.link_id, link.code_node_id, link.slm_fact_id,
405
+ link.slm_entity_id, link.link_type.value, link.confidence,
406
+ link.created_at, link.last_verified, int(link.is_stale),
407
+ ),
408
+ )
409
+
410
+ def get_links_for_node(self, code_node_id: str) -> list[CodeMemoryLink]:
411
+ """All bridge links for a code node."""
412
+ rows = self.execute(
413
+ "SELECT * FROM code_memory_links WHERE code_node_id = ?",
414
+ (code_node_id,),
415
+ )
416
+ return [self._row_to_link(r) for r in rows]
417
+
418
+ def get_links_for_fact(self, slm_fact_id: str) -> list[CodeMemoryLink]:
419
+ """All bridge links for an SLM fact."""
420
+ rows = self.execute(
421
+ "SELECT * FROM code_memory_links WHERE slm_fact_id = ?",
422
+ (slm_fact_id,),
423
+ )
424
+ return [self._row_to_link(r) for r in rows]
425
+
426
+ # ------------------------------------------------------------------
427
+ # Row converters
428
+ # ------------------------------------------------------------------
429
+
430
+ @staticmethod
431
+ def _row_to_node(row: sqlite3.Row) -> GraphNode:
432
+ d = dict(row)
433
+ return GraphNode(
434
+ node_id=d["node_id"],
435
+ kind=NodeKind(d["kind"]),
436
+ name=d["name"],
437
+ qualified_name=d["qualified_name"],
438
+ file_path=d["file_path"],
439
+ line_start=d["line_start"],
440
+ line_end=d["line_end"],
441
+ language=d["language"],
442
+ parent_name=d.get("parent_name"),
443
+ signature=d.get("signature"),
444
+ docstring=d.get("docstring"),
445
+ is_test=bool(d.get("is_test", 0)),
446
+ content_hash=d.get("content_hash"),
447
+ community_id=d.get("community_id"),
448
+ extra_json=d.get("extra_json", "{}"),
449
+ created_at=d["created_at"],
450
+ updated_at=d["updated_at"],
451
+ )
452
+
453
+ @staticmethod
454
+ def _row_to_edge(row: sqlite3.Row) -> GraphEdge:
455
+ d = dict(row)
456
+ return GraphEdge(
457
+ edge_id=d["edge_id"],
458
+ kind=EdgeKind(d["kind"]),
459
+ source_node_id=d["source_node_id"],
460
+ target_node_id=d["target_node_id"],
461
+ file_path=d["file_path"],
462
+ line=d["line"],
463
+ confidence=d["confidence"],
464
+ extra_json=d.get("extra_json", "{}"),
465
+ created_at=d["created_at"],
466
+ updated_at=d["updated_at"],
467
+ )
468
+
469
+ @staticmethod
470
+ def _row_to_link(row: sqlite3.Row) -> CodeMemoryLink:
471
+ d = dict(row)
472
+ return CodeMemoryLink(
473
+ link_id=d["link_id"],
474
+ code_node_id=d["code_node_id"],
475
+ slm_fact_id=d["slm_fact_id"],
476
+ slm_entity_id=d.get("slm_entity_id"),
477
+ link_type=LinkType(d["link_type"]),
478
+ confidence=d["confidence"],
479
+ created_at=d["created_at"],
480
+ last_verified=d.get("last_verified"),
481
+ is_stale=bool(d.get("is_stale", 0)),
482
+ )
@@ -0,0 +1,78 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory v3.4 — CodeGraph Module
4
+
5
+ """Base extractor ABC for language-specific AST extraction.
6
+
7
+ Each language implements extract_functions, extract_classes,
8
+ extract_imports, and extract_calls. The concrete extract() method
9
+ orchestrates them in order and returns (nodes, edges).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from abc import ABC, abstractmethod
15
+ from typing import Any
16
+
17
+ from superlocalmemory.code_graph.config import CodeGraphConfig
18
+ from superlocalmemory.code_graph.models import GraphEdge, GraphNode
19
+
20
+
21
+ class BaseExtractor(ABC):
22
+ """Abstract base class for language-specific AST extractors.
23
+
24
+ Each instance processes exactly one file. No mutable class-level state (HR-12).
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ root_node: Any, # tree_sitter.Node
30
+ source_bytes: bytes,
31
+ file_path: str, # Relative path
32
+ config: CodeGraphConfig,
33
+ ) -> None:
34
+ self._root = root_node
35
+ self._source = source_bytes
36
+ self._file_path = file_path
37
+ self._config = config
38
+
39
+ @abstractmethod
40
+ def extract_functions(self) -> list[GraphNode]:
41
+ """Extract all function/method definitions."""
42
+
43
+ @abstractmethod
44
+ def extract_classes(self) -> list[GraphNode]:
45
+ """Extract all class/interface definitions."""
46
+
47
+ @abstractmethod
48
+ def extract_imports(self) -> tuple[list[GraphEdge], dict[str, tuple[str, str]]]:
49
+ """Extract import statements.
50
+
51
+ Returns:
52
+ (edges, import_map) where import_map is
53
+ {local_name: (module_path, imported_name)}.
54
+ """
55
+
56
+ @abstractmethod
57
+ def extract_calls(
58
+ self, import_map: dict[str, tuple[str, str]]
59
+ ) -> list[GraphEdge]:
60
+ """Extract function/method calls.
61
+
62
+ Uses import_map for initial resolution.
63
+ """
64
+
65
+ def extract(self) -> tuple[list[GraphNode], list[GraphEdge]]:
66
+ """Run all extractors in order. Non-abstract.
67
+
68
+ Step 1: classes = extract_classes()
69
+ Step 2: functions = extract_functions()
70
+ Step 3: import_edges, import_map = extract_imports()
71
+ Step 4: call_edges = extract_calls(import_map)
72
+ Step 5: Return (classes + functions, import_edges + call_edges)
73
+ """
74
+ classes = self.extract_classes()
75
+ functions = self.extract_functions()
76
+ import_edges, import_map = self.extract_imports()
77
+ call_edges = self.extract_calls(import_map)
78
+ return (classes + functions, import_edges + call_edges)