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.
- package/package.json +1 -1
- package/pyproject.toml +9 -1
- package/src/superlocalmemory/cli/commands.py +138 -22
- package/src/superlocalmemory/cli/daemon.py +372 -0
- package/src/superlocalmemory/cli/main.py +8 -0
- 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/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/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/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,350 @@
|
|
|
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
|
+
"""FlowDetector — entry point detection and execution flow tracing.
|
|
6
|
+
|
|
7
|
+
Detects entry points (nodes with no incoming CALLS edges),
|
|
8
|
+
traces BFS forward through CALLS edges, and scores criticality.
|
|
9
|
+
Flows stored in graph_metadata as JSON.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import re
|
|
17
|
+
from collections import deque
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from superlocalmemory.code_graph.database import CodeGraphDatabase
|
|
22
|
+
from superlocalmemory.code_graph.models import EdgeKind, NodeKind
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Result dataclasses
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class FlowResult:
|
|
33
|
+
"""A detected execution flow."""
|
|
34
|
+
name: str
|
|
35
|
+
entry_node_id: str
|
|
36
|
+
depth: int
|
|
37
|
+
node_count: int
|
|
38
|
+
file_count: int
|
|
39
|
+
criticality: float
|
|
40
|
+
path_node_ids: tuple[str, ...]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Security keywords (frozen constant)
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
SECURITY_KEYWORDS: frozenset[str] = frozenset([
|
|
48
|
+
"auth", "login", "password", "token", "session", "crypt", "secret",
|
|
49
|
+
"credential", "permission", "sql", "query", "execute", "connect",
|
|
50
|
+
"socket", "request", "http", "sanitize", "validate", "encrypt",
|
|
51
|
+
"decrypt", "hash", "sign", "verify", "admin", "privilege",
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
# Default entry point name patterns
|
|
55
|
+
_DEFAULT_ENTRY_PATTERNS: tuple[str, ...] = (
|
|
56
|
+
r"^main$", r"^__main__$", r"^test_", r"^Test[A-Z]",
|
|
57
|
+
r"^on_", r"^handle_", r"^cli_", r"^command_",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# FlowDetector
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class FlowDetector:
|
|
66
|
+
"""Detect execution flows through the code graph.
|
|
67
|
+
|
|
68
|
+
Identifies entry points (nodes with no incoming CALLS edges or
|
|
69
|
+
matching configurable patterns), traces BFS forward through CALLS
|
|
70
|
+
edges, and computes criticality scores.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
db: CodeGraphDatabase,
|
|
76
|
+
entry_patterns: tuple[str, ...] | None = None,
|
|
77
|
+
) -> None:
|
|
78
|
+
self._db = db
|
|
79
|
+
self._entry_patterns = entry_patterns or _DEFAULT_ENTRY_PATTERNS
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# Public API
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def detect_entry_points(self) -> list[str]:
|
|
86
|
+
"""Find nodes with no incoming CALLS edges.
|
|
87
|
+
|
|
88
|
+
Also includes nodes matching entry point name patterns.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of node_id strings for entry points.
|
|
92
|
+
"""
|
|
93
|
+
# Get all nodes that are functions or methods (not files/modules)
|
|
94
|
+
all_nodes = self._db.execute(
|
|
95
|
+
"""SELECT node_id, name, kind FROM graph_nodes
|
|
96
|
+
WHERE kind IN ('function', 'method')""",
|
|
97
|
+
(),
|
|
98
|
+
)
|
|
99
|
+
if not all_nodes:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
# Get all nodes that HAVE incoming CALLS edges
|
|
103
|
+
called_nodes = self._db.execute(
|
|
104
|
+
"""SELECT DISTINCT target_node_id
|
|
105
|
+
FROM graph_edges
|
|
106
|
+
WHERE kind = ?""",
|
|
107
|
+
(EdgeKind.CALLS.value,),
|
|
108
|
+
)
|
|
109
|
+
called_ids = {row["target_node_id"] for row in called_nodes}
|
|
110
|
+
|
|
111
|
+
entry_points: list[str] = []
|
|
112
|
+
for row in all_nodes:
|
|
113
|
+
node_id = row["node_id"]
|
|
114
|
+
name = row["name"]
|
|
115
|
+
|
|
116
|
+
# No incoming CALLS -> entry point
|
|
117
|
+
if node_id not in called_ids:
|
|
118
|
+
entry_points.append(node_id)
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
# Pattern match check (even if called, these are entry points)
|
|
122
|
+
if self._matches_entry_pattern(name):
|
|
123
|
+
entry_points.append(node_id)
|
|
124
|
+
|
|
125
|
+
return entry_points
|
|
126
|
+
|
|
127
|
+
def trace_flow(
|
|
128
|
+
self, entry_node_id: str, max_depth: int = 15
|
|
129
|
+
) -> FlowResult:
|
|
130
|
+
"""BFS forward through CALLS edges from an entry point.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
entry_node_id: Starting node ID.
|
|
134
|
+
max_depth: Maximum BFS depth.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
FlowResult with path, depth, and metadata.
|
|
138
|
+
"""
|
|
139
|
+
# Load entry node info
|
|
140
|
+
entry_rows = self._db.execute(
|
|
141
|
+
"SELECT node_id, name, kind, file_path FROM graph_nodes WHERE node_id = ?",
|
|
142
|
+
(entry_node_id,),
|
|
143
|
+
)
|
|
144
|
+
if not entry_rows:
|
|
145
|
+
return FlowResult(
|
|
146
|
+
name="unknown",
|
|
147
|
+
entry_node_id=entry_node_id,
|
|
148
|
+
depth=0,
|
|
149
|
+
node_count=0,
|
|
150
|
+
file_count=0,
|
|
151
|
+
criticality=0.0,
|
|
152
|
+
path_node_ids=(),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
entry_name = entry_rows[0]["name"]
|
|
156
|
+
|
|
157
|
+
# BFS forward
|
|
158
|
+
visited: set[str] = {entry_node_id}
|
|
159
|
+
path: list[str] = [entry_node_id]
|
|
160
|
+
frontier: set[str] = {entry_node_id}
|
|
161
|
+
depth = 0
|
|
162
|
+
|
|
163
|
+
while frontier and depth < max_depth:
|
|
164
|
+
next_frontier: set[str] = set()
|
|
165
|
+
for nid in frontier:
|
|
166
|
+
outgoing = self._db.execute(
|
|
167
|
+
"""SELECT target_node_id FROM graph_edges
|
|
168
|
+
WHERE source_node_id = ? AND kind = ?""",
|
|
169
|
+
(nid, EdgeKind.CALLS.value),
|
|
170
|
+
)
|
|
171
|
+
for row in outgoing:
|
|
172
|
+
target = row["target_node_id"]
|
|
173
|
+
if target not in visited:
|
|
174
|
+
visited.add(target)
|
|
175
|
+
path.append(target)
|
|
176
|
+
next_frontier.add(target)
|
|
177
|
+
frontier = next_frontier
|
|
178
|
+
depth += 1
|
|
179
|
+
|
|
180
|
+
# Collect file paths for file_count
|
|
181
|
+
file_paths = self._get_file_paths(path)
|
|
182
|
+
|
|
183
|
+
return FlowResult(
|
|
184
|
+
name=f"flow_{entry_name}",
|
|
185
|
+
entry_node_id=entry_node_id,
|
|
186
|
+
depth=depth,
|
|
187
|
+
node_count=len(path),
|
|
188
|
+
file_count=len(file_paths),
|
|
189
|
+
criticality=0.0, # Will be computed later
|
|
190
|
+
path_node_ids=tuple(path),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def trace_all_flows(self, max_depth: int = 15) -> list[FlowResult]:
|
|
194
|
+
"""Detect all entry points, trace each, score criticality.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
List of FlowResult sorted by criticality (highest first).
|
|
198
|
+
"""
|
|
199
|
+
entry_points = self.detect_entry_points()
|
|
200
|
+
flows: list[FlowResult] = []
|
|
201
|
+
|
|
202
|
+
for ep_id in entry_points:
|
|
203
|
+
flow = self.trace_flow(ep_id, max_depth)
|
|
204
|
+
# Skip trivial single-node flows
|
|
205
|
+
if flow.node_count < 2:
|
|
206
|
+
continue
|
|
207
|
+
# Compute criticality
|
|
208
|
+
criticality = self._compute_criticality(flow)
|
|
209
|
+
flow = FlowResult(
|
|
210
|
+
name=flow.name,
|
|
211
|
+
entry_node_id=flow.entry_node_id,
|
|
212
|
+
depth=flow.depth,
|
|
213
|
+
node_count=flow.node_count,
|
|
214
|
+
file_count=flow.file_count,
|
|
215
|
+
criticality=criticality,
|
|
216
|
+
path_node_ids=flow.path_node_ids,
|
|
217
|
+
)
|
|
218
|
+
flows.append(flow)
|
|
219
|
+
|
|
220
|
+
# Sort by criticality descending
|
|
221
|
+
flows.sort(key=lambda f: -f.criticality)
|
|
222
|
+
|
|
223
|
+
# Store in metadata
|
|
224
|
+
self._store_flows(flows)
|
|
225
|
+
|
|
226
|
+
return flows
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Internal helpers
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def _matches_entry_pattern(self, name: str) -> bool:
|
|
233
|
+
"""Check if node name matches any entry point pattern."""
|
|
234
|
+
for pattern in self._entry_patterns:
|
|
235
|
+
if re.search(pattern, name):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def _get_file_paths(self, node_ids: list[str]) -> set[str]:
|
|
240
|
+
"""Get unique file paths for a list of node IDs."""
|
|
241
|
+
if not node_ids:
|
|
242
|
+
return set()
|
|
243
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
244
|
+
rows = self._db.execute(
|
|
245
|
+
f"SELECT DISTINCT file_path FROM graph_nodes WHERE node_id IN ({placeholders})",
|
|
246
|
+
tuple(node_ids),
|
|
247
|
+
)
|
|
248
|
+
return {row["file_path"] for row in rows}
|
|
249
|
+
|
|
250
|
+
def _compute_criticality(self, flow: FlowResult) -> float:
|
|
251
|
+
"""Compute criticality score for a flow.
|
|
252
|
+
|
|
253
|
+
5-factor weighted score:
|
|
254
|
+
- depth (0.10): deeper flows are more critical
|
|
255
|
+
- node_count (0.15): more nodes = more complex
|
|
256
|
+
- file_count (0.30): cross-file flows are riskier
|
|
257
|
+
- test_coverage (0.20): untested paths are riskier
|
|
258
|
+
- security_keywords (0.25): security-related code is critical
|
|
259
|
+
"""
|
|
260
|
+
# Depth score (weight 0.10)
|
|
261
|
+
depth_score = min(flow.depth / 10.0, 1.0) * 0.10
|
|
262
|
+
|
|
263
|
+
# Node count score (weight 0.15)
|
|
264
|
+
node_score = min(flow.node_count / 20.0, 1.0) * 0.15
|
|
265
|
+
|
|
266
|
+
# File spread score (weight 0.30)
|
|
267
|
+
file_score = min((flow.file_count - 1) / 4.0, 1.0) * 0.30
|
|
268
|
+
|
|
269
|
+
# Test coverage gap (weight 0.20)
|
|
270
|
+
tested_count = self._count_tested_nodes(flow.path_node_ids)
|
|
271
|
+
coverage_score = (
|
|
272
|
+
(1.0 - tested_count / max(flow.node_count, 1)) * 0.20
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Security sensitivity (weight 0.25)
|
|
276
|
+
security_hits = self._count_security_nodes(flow.path_node_ids)
|
|
277
|
+
security_score = (
|
|
278
|
+
min(security_hits / max(flow.node_count, 1), 1.0) * 0.25
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
return depth_score + node_score + file_score + coverage_score + security_score
|
|
282
|
+
|
|
283
|
+
def _count_tested_nodes(self, node_ids: tuple[str, ...]) -> int:
|
|
284
|
+
"""Count nodes that have TESTED_BY edges."""
|
|
285
|
+
if not node_ids:
|
|
286
|
+
return 0
|
|
287
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
288
|
+
rows = self._db.execute(
|
|
289
|
+
f"""SELECT COUNT(DISTINCT source_node_id) as cnt
|
|
290
|
+
FROM graph_edges
|
|
291
|
+
WHERE source_node_id IN ({placeholders})
|
|
292
|
+
AND kind = ?""",
|
|
293
|
+
(*node_ids, EdgeKind.TESTED_BY.value),
|
|
294
|
+
)
|
|
295
|
+
return rows[0]["cnt"] if rows else 0
|
|
296
|
+
|
|
297
|
+
def _count_security_nodes(self, node_ids: tuple[str, ...]) -> int:
|
|
298
|
+
"""Count nodes whose names contain security keywords."""
|
|
299
|
+
if not node_ids:
|
|
300
|
+
return 0
|
|
301
|
+
placeholders = ",".join("?" for _ in node_ids)
|
|
302
|
+
rows = self._db.execute(
|
|
303
|
+
f"SELECT node_id, name FROM graph_nodes WHERE node_id IN ({placeholders})",
|
|
304
|
+
tuple(node_ids),
|
|
305
|
+
)
|
|
306
|
+
count = 0
|
|
307
|
+
for row in rows:
|
|
308
|
+
name_lower = row["name"].lower()
|
|
309
|
+
if any(kw in name_lower for kw in SECURITY_KEYWORDS):
|
|
310
|
+
count += 1
|
|
311
|
+
return count
|
|
312
|
+
|
|
313
|
+
def _store_flows(self, flows: list[FlowResult]) -> None:
|
|
314
|
+
"""Store flows in graph_metadata as JSON."""
|
|
315
|
+
flow_data = [
|
|
316
|
+
{
|
|
317
|
+
"name": f.name,
|
|
318
|
+
"entry_node_id": f.entry_node_id,
|
|
319
|
+
"depth": f.depth,
|
|
320
|
+
"node_count": f.node_count,
|
|
321
|
+
"file_count": f.file_count,
|
|
322
|
+
"criticality": f.criticality,
|
|
323
|
+
"path_node_ids": list(f.path_node_ids),
|
|
324
|
+
}
|
|
325
|
+
for f in flows
|
|
326
|
+
]
|
|
327
|
+
self._db.set_metadata("flows", json.dumps(flow_data))
|
|
328
|
+
|
|
329
|
+
def get_stored_flows(self) -> list[FlowResult]:
|
|
330
|
+
"""Load flows from graph_metadata."""
|
|
331
|
+
raw = self._db.get_metadata("flows")
|
|
332
|
+
if not raw:
|
|
333
|
+
return []
|
|
334
|
+
try:
|
|
335
|
+
flow_data = json.loads(raw)
|
|
336
|
+
except (json.JSONDecodeError, TypeError):
|
|
337
|
+
return []
|
|
338
|
+
|
|
339
|
+
return [
|
|
340
|
+
FlowResult(
|
|
341
|
+
name=f["name"],
|
|
342
|
+
entry_node_id=f["entry_node_id"],
|
|
343
|
+
depth=f["depth"],
|
|
344
|
+
node_count=f["node_count"],
|
|
345
|
+
file_count=f["file_count"],
|
|
346
|
+
criticality=f["criticality"],
|
|
347
|
+
path_node_ids=tuple(f["path_node_ids"]),
|
|
348
|
+
)
|
|
349
|
+
for f in flow_data
|
|
350
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
"""Git post-commit hook for automatic code graph updates.
|
|
6
|
+
|
|
7
|
+
Install/uninstall functions for the hook, plus the run_post_commit
|
|
8
|
+
entry point that the hook invokes. Non-destructive: never modifies
|
|
9
|
+
git state, never blocks the commit. Idempotent installation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Constants
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_HOOK_MARKER = "# SLM-CodeGraph-Hook"
|
|
28
|
+
_HOOK_START = "# --- SLM-CodeGraph-Hook-Start ---"
|
|
29
|
+
_HOOK_END = "# --- SLM-CodeGraph-Hook-End ---"
|
|
30
|
+
|
|
31
|
+
_HOOK_CONTENT = f"""
|
|
32
|
+
{_HOOK_START}
|
|
33
|
+
{_HOOK_MARKER}
|
|
34
|
+
# Trigger SLM CodeGraph incremental update after commit
|
|
35
|
+
python3 -m superlocalmemory.code_graph.git_hooks "$PWD" &
|
|
36
|
+
{_HOOK_END}
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
_SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({
|
|
40
|
+
".py", ".ts", ".tsx", ".js", ".jsx",
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Public API
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def install_post_commit_hook(repo_root: str | Path) -> dict:
|
|
49
|
+
"""Install SLM post-commit hook in the repository.
|
|
50
|
+
|
|
51
|
+
Idempotent: if hook is already present, returns "already_present".
|
|
52
|
+
If a hook file exists, appends our section. Otherwise creates new.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
{"success": True, "action": "installed" | "already_present" | "appended"}
|
|
56
|
+
"""
|
|
57
|
+
repo_root = Path(repo_root)
|
|
58
|
+
hook_path = repo_root / ".git" / "hooks" / "post-commit"
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
# Ensure hooks directory exists
|
|
62
|
+
hook_path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
|
|
64
|
+
if hook_path.exists():
|
|
65
|
+
content = hook_path.read_text()
|
|
66
|
+
|
|
67
|
+
# Idempotent check
|
|
68
|
+
if _HOOK_MARKER in content:
|
|
69
|
+
return {"success": True, "action": "already_present"}
|
|
70
|
+
|
|
71
|
+
# Append to existing hook
|
|
72
|
+
new_content = content.rstrip() + "\n" + _HOOK_CONTENT
|
|
73
|
+
hook_path.write_text(new_content)
|
|
74
|
+
_make_executable(hook_path)
|
|
75
|
+
return {"success": True, "action": "appended"}
|
|
76
|
+
|
|
77
|
+
# Create new hook
|
|
78
|
+
new_content = "#!/bin/sh\n" + _HOOK_CONTENT
|
|
79
|
+
hook_path.write_text(new_content)
|
|
80
|
+
_make_executable(hook_path)
|
|
81
|
+
return {"success": True, "action": "installed"}
|
|
82
|
+
|
|
83
|
+
except PermissionError:
|
|
84
|
+
return {
|
|
85
|
+
"success": False,
|
|
86
|
+
"error": "Permission denied writing git hook. Check .git/hooks/ permissions.",
|
|
87
|
+
}
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
return {"success": False, "error": str(exc)}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def uninstall_post_commit_hook(repo_root: str | Path) -> dict:
|
|
93
|
+
"""Remove SLM post-commit hook from the repository.
|
|
94
|
+
|
|
95
|
+
Only removes our section (between _HOOK_START and _HOOK_END markers).
|
|
96
|
+
Does not touch other hook content.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
{"success": True, "action": "removed" | "not_found"}
|
|
100
|
+
"""
|
|
101
|
+
repo_root = Path(repo_root)
|
|
102
|
+
hook_path = repo_root / ".git" / "hooks" / "post-commit"
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
if not hook_path.exists():
|
|
106
|
+
return {"success": True, "action": "not_found"}
|
|
107
|
+
|
|
108
|
+
content = hook_path.read_text()
|
|
109
|
+
|
|
110
|
+
if _HOOK_MARKER not in content:
|
|
111
|
+
return {"success": True, "action": "not_found"}
|
|
112
|
+
|
|
113
|
+
# Remove the section between markers
|
|
114
|
+
lines = content.split("\n")
|
|
115
|
+
new_lines: list[str] = []
|
|
116
|
+
in_section = False
|
|
117
|
+
|
|
118
|
+
for line in lines:
|
|
119
|
+
if _HOOK_START in line:
|
|
120
|
+
in_section = True
|
|
121
|
+
continue
|
|
122
|
+
if _HOOK_END in line:
|
|
123
|
+
in_section = False
|
|
124
|
+
continue
|
|
125
|
+
if not in_section:
|
|
126
|
+
new_lines.append(line)
|
|
127
|
+
|
|
128
|
+
new_content = "\n".join(new_lines).strip()
|
|
129
|
+
|
|
130
|
+
if not new_content or new_content == "#!/bin/sh":
|
|
131
|
+
# Hook file is now empty, remove it
|
|
132
|
+
hook_path.unlink()
|
|
133
|
+
else:
|
|
134
|
+
hook_path.write_text(new_content + "\n")
|
|
135
|
+
|
|
136
|
+
return {"success": True, "action": "removed"}
|
|
137
|
+
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
return {"success": False, "error": str(exc)}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_post_commit(repo_root: str | Path) -> dict:
|
|
143
|
+
"""Execute the post-commit graph update.
|
|
144
|
+
|
|
145
|
+
Detects changed files from the last commit and triggers
|
|
146
|
+
incremental update. Called by the installed git hook.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
{"success": True, "files_updated": int, "duration_ms": int}
|
|
150
|
+
"""
|
|
151
|
+
repo_root = Path(repo_root)
|
|
152
|
+
t0 = time.time()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# Detect changed files
|
|
156
|
+
result = subprocess.run(
|
|
157
|
+
["git", "diff", "--name-only", "HEAD~1", "HEAD", "--"],
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
timeout=30,
|
|
161
|
+
cwd=str(repo_root),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if result.returncode != 0:
|
|
165
|
+
return {
|
|
166
|
+
"success": False,
|
|
167
|
+
"error": f"git diff failed: {result.stderr.strip()}",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Filter to supported extensions
|
|
171
|
+
all_files = [
|
|
172
|
+
f.strip() for f in result.stdout.strip().split("\n") if f.strip()
|
|
173
|
+
]
|
|
174
|
+
supported = [
|
|
175
|
+
f for f in all_files
|
|
176
|
+
if any(f.endswith(ext) for ext in _SUPPORTED_EXTENSIONS)
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
duration_ms = int((time.time() - t0) * 1000)
|
|
180
|
+
|
|
181
|
+
if not supported:
|
|
182
|
+
return {
|
|
183
|
+
"success": True,
|
|
184
|
+
"files_updated": 0,
|
|
185
|
+
"duration_ms": duration_ms,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Trigger incremental update (lazy import to avoid startup cost)
|
|
189
|
+
# In production this would call the CodeGraphService
|
|
190
|
+
# For now just return the list of files that would be updated
|
|
191
|
+
duration_ms = int((time.time() - t0) * 1000)
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
"success": True,
|
|
195
|
+
"files_updated": len(supported),
|
|
196
|
+
"duration_ms": duration_ms,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
except subprocess.TimeoutExpired:
|
|
200
|
+
return {"success": False, "error": "git diff timed out"}
|
|
201
|
+
except FileNotFoundError:
|
|
202
|
+
return {"success": False, "error": "git not found on PATH"}
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
return {"success": False, "error": str(exc)}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Helpers
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
def _make_executable(path: Path) -> None:
|
|
212
|
+
"""Make a file executable (chmod +x)."""
|
|
213
|
+
current = path.stat().st_mode
|
|
214
|
+
path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# CLI entry point (called by hook)
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
import sys
|
|
223
|
+
if len(sys.argv) > 1:
|
|
224
|
+
result = run_post_commit(sys.argv[1])
|
|
225
|
+
if not result.get("success"):
|
|
226
|
+
logger.error("Post-commit hook failed: %s", result.get("error"))
|