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,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"))