superlocalmemory 3.3.19 → 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.
- package/package.json +1 -1
- package/pyproject.toml +9 -1
- package/src/superlocalmemory/cli/commands.py +140 -23
- package/src/superlocalmemory/cli/daemon.py +372 -0
- package/src/superlocalmemory/cli/main.py +10 -2
- package/src/superlocalmemory/cli/pending_store.py +158 -0
- package/src/superlocalmemory/cli/setup_wizard.py +39 -6
- package/src/superlocalmemory/code_graph/__init__.py +46 -0
- package/src/superlocalmemory/code_graph/blast_radius.py +177 -0
- package/src/superlocalmemory/code_graph/bridge/__init__.py +36 -0
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +464 -0
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +195 -0
- package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +159 -0
- package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +170 -0
- package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +152 -0
- package/src/superlocalmemory/code_graph/changes.py +363 -0
- package/src/superlocalmemory/code_graph/communities.py +299 -0
- package/src/superlocalmemory/code_graph/config.py +88 -0
- package/src/superlocalmemory/code_graph/database.py +482 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +78 -0
- package/src/superlocalmemory/code_graph/extractors/python.py +413 -0
- package/src/superlocalmemory/code_graph/extractors/typescript.py +556 -0
- package/src/superlocalmemory/code_graph/flows.py +350 -0
- package/src/superlocalmemory/code_graph/git_hooks.py +226 -0
- package/src/superlocalmemory/code_graph/graph_engine.py +295 -0
- package/src/superlocalmemory/code_graph/graph_store.py +158 -0
- package/src/superlocalmemory/code_graph/incremental.py +200 -0
- package/src/superlocalmemory/code_graph/models.py +130 -0
- package/src/superlocalmemory/code_graph/parser.py +507 -0
- package/src/superlocalmemory/code_graph/resolver.py +321 -0
- package/src/superlocalmemory/code_graph/search.py +460 -0
- package/src/superlocalmemory/code_graph/service.py +95 -0
- package/src/superlocalmemory/code_graph/watcher.py +207 -0
- package/src/superlocalmemory/core/config.py +4 -3
- package/src/superlocalmemory/core/embedding_worker.py +4 -2
- package/src/superlocalmemory/core/embeddings.py +8 -2
- package/src/superlocalmemory/core/engine.py +32 -0
- package/src/superlocalmemory/core/engine_wiring.py +5 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -3
- package/src/superlocalmemory/core/store_pipeline.py +23 -1
- package/src/superlocalmemory/encoding/fact_extractor.py +68 -7
- package/src/superlocalmemory/infra/event_bus.py +5 -0
- package/src/superlocalmemory/mcp/server.py +23 -0
- package/src/superlocalmemory/mcp/tools_code_graph.py +1592 -0
- package/src/superlocalmemory/retrieval/agentic.py +89 -17
- package/src/superlocalmemory/retrieval/engine.py +137 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +6 -2
- package/src/superlocalmemory/retrieval/spreading_activation.py +5 -3
- package/src/superlocalmemory/retrieval/strategy.py +16 -0
- package/src/superlocalmemory/server/api.py +4 -2
- package/src/superlocalmemory/server/ui.py +5 -2
- package/src/superlocalmemory/storage/schema_code_graph.py +239 -0
- package/src/superlocalmemory/ui/index.html +1879 -0
- package/src/superlocalmemory/ui/js/agents.js +192 -0
- package/src/superlocalmemory/ui/js/auto-settings.js +399 -0
- package/src/superlocalmemory/ui/js/behavioral.js +276 -0
- package/src/superlocalmemory/ui/js/clusters.js +206 -0
- package/src/superlocalmemory/ui/js/compliance.js +252 -0
- package/src/superlocalmemory/ui/js/core.js +246 -0
- package/src/superlocalmemory/ui/js/dashboard.js +110 -0
- package/src/superlocalmemory/ui/js/events.js +178 -0
- package/src/superlocalmemory/ui/js/fact-detail.js +92 -0
- package/src/superlocalmemory/ui/js/feedback.js +333 -0
- package/src/superlocalmemory/ui/js/graph-core.js +447 -0
- package/src/superlocalmemory/ui/js/graph-filters.js +220 -0
- package/src/superlocalmemory/ui/js/graph-interactions.js +351 -0
- package/src/superlocalmemory/ui/js/graph-ui.js +214 -0
- package/src/superlocalmemory/ui/js/ide-status.js +102 -0
- package/src/superlocalmemory/ui/js/init.js +45 -0
- package/src/superlocalmemory/ui/js/learning.js +435 -0
- package/src/superlocalmemory/ui/js/lifecycle.js +298 -0
- package/src/superlocalmemory/ui/js/math-health.js +98 -0
- package/src/superlocalmemory/ui/js/memories.js +264 -0
- package/src/superlocalmemory/ui/js/modal.js +357 -0
- package/src/superlocalmemory/ui/js/patterns.js +93 -0
- package/src/superlocalmemory/ui/js/profiles.js +236 -0
- package/src/superlocalmemory/ui/js/recall-lab.js +292 -0
- package/src/superlocalmemory/ui/js/search.js +59 -0
- package/src/superlocalmemory/ui/js/settings.js +224 -0
- package/src/superlocalmemory/ui/js/timeline.js +32 -0
- 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)
|