claude-mpm 4.1.10__py3-none-any.whl → 4.1.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/INSTRUCTIONS.md +8 -0
- claude_mpm/cli/__init__.py +11 -0
- claude_mpm/cli/commands/analyze.py +2 -1
- claude_mpm/cli/commands/configure.py +9 -8
- claude_mpm/cli/commands/configure_tui.py +3 -1
- claude_mpm/cli/commands/dashboard.py +288 -0
- claude_mpm/cli/commands/debug.py +0 -1
- claude_mpm/cli/commands/mpm_init.py +442 -0
- claude_mpm/cli/commands/mpm_init_handler.py +84 -0
- claude_mpm/cli/parsers/base_parser.py +15 -0
- claude_mpm/cli/parsers/dashboard_parser.py +113 -0
- claude_mpm/cli/parsers/mpm_init_parser.py +128 -0
- claude_mpm/constants.py +10 -0
- claude_mpm/core/config.py +18 -0
- claude_mpm/core/instruction_reinforcement_hook.py +266 -0
- claude_mpm/core/pm_hook_interceptor.py +105 -8
- claude_mpm/dashboard/analysis_runner.py +52 -25
- claude_mpm/dashboard/static/built/components/activity-tree.js +1 -1
- claude_mpm/dashboard/static/built/components/code-tree.js +2 -0
- claude_mpm/dashboard/static/built/components/code-viewer.js +2 -0
- claude_mpm/dashboard/static/built/components/event-viewer.js +1 -1
- claude_mpm/dashboard/static/built/dashboard.js +1 -1
- claude_mpm/dashboard/static/built/socket-client.js +1 -1
- claude_mpm/dashboard/static/css/code-tree.css +330 -1
- claude_mpm/dashboard/static/dist/components/activity-tree.js +1 -1
- claude_mpm/dashboard/static/dist/components/code-tree.js +2593 -2
- claude_mpm/dashboard/static/dist/components/event-viewer.js +1 -1
- claude_mpm/dashboard/static/dist/dashboard.js +1 -1
- claude_mpm/dashboard/static/dist/socket-client.js +1 -1
- claude_mpm/dashboard/static/js/components/activity-tree.js +212 -13
- claude_mpm/dashboard/static/js/components/build-tracker.js +15 -13
- claude_mpm/dashboard/static/js/components/code-tree.js +2503 -917
- claude_mpm/dashboard/static/js/components/event-viewer.js +58 -19
- claude_mpm/dashboard/static/js/dashboard.js +46 -44
- claude_mpm/dashboard/static/js/socket-client.js +74 -32
- claude_mpm/dashboard/templates/index.html +25 -20
- claude_mpm/services/agents/deployment/agent_template_builder.py +11 -7
- claude_mpm/services/agents/memory/memory_format_service.py +3 -1
- claude_mpm/services/cli/agent_cleanup_service.py +1 -4
- claude_mpm/services/cli/socketio_manager.py +39 -8
- claude_mpm/services/cli/startup_checker.py +0 -1
- claude_mpm/services/core/cache_manager.py +0 -1
- claude_mpm/services/infrastructure/monitoring.py +1 -1
- claude_mpm/services/socketio/event_normalizer.py +64 -0
- claude_mpm/services/socketio/handlers/code_analysis.py +449 -0
- claude_mpm/services/socketio/server/connection_manager.py +3 -1
- claude_mpm/tools/code_tree_analyzer.py +930 -24
- claude_mpm/tools/code_tree_builder.py +0 -1
- claude_mpm/tools/code_tree_events.py +113 -15
- {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/METADATA +2 -1
- {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/RECORD +56 -48
- {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/WHEEL +0 -0
- {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/entry_points.txt +0 -0
- {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-4.1.10.dist-info → claude_mpm-4.1.12.dist-info}/top_level.txt +0 -0
|
@@ -62,7 +62,9 @@ class MemoryFormatService:
|
|
|
62
62
|
line = line.strip()
|
|
63
63
|
# Skip headers, empty lines, and metadata
|
|
64
64
|
if (
|
|
65
|
-
not line
|
|
65
|
+
not line
|
|
66
|
+
or line.startswith(("#", "Last Updated:", "**"))
|
|
67
|
+
or line == "---"
|
|
66
68
|
):
|
|
67
69
|
continue
|
|
68
70
|
|
|
@@ -233,10 +233,7 @@ class AgentCleanupService(IAgentCleanupService):
|
|
|
233
233
|
all_agents = multi_source_service.discover_agents_from_all_sources()
|
|
234
234
|
|
|
235
235
|
# Detect orphaned agents
|
|
236
|
-
return multi_source_service.detect_orphaned_agents(
|
|
237
|
-
agents_dir, all_agents
|
|
238
|
-
)
|
|
239
|
-
|
|
236
|
+
return multi_source_service.detect_orphaned_agents(agents_dir, all_agents)
|
|
240
237
|
|
|
241
238
|
except Exception as e:
|
|
242
239
|
self.logger.error(f"Error finding orphaned agents: {e}", exc_info=True)
|
|
@@ -172,10 +172,18 @@ class SocketIOManager(ISocketIOManager):
|
|
|
172
172
|
|
|
173
173
|
# Check if server already running on this port
|
|
174
174
|
if self.is_server_running(target_port):
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
# Verify the server is healthy and responding
|
|
176
|
+
if self.wait_for_server(target_port, timeout=2):
|
|
177
|
+
self.logger.info(
|
|
178
|
+
f"Healthy Socket.IO server already running on port {target_port}"
|
|
179
|
+
)
|
|
180
|
+
return True, self.get_server_info(target_port)
|
|
181
|
+
# Server exists but not responding, try to clean it up
|
|
182
|
+
self.logger.warning(
|
|
183
|
+
f"Socket.IO server on port {target_port} not responding, attempting cleanup"
|
|
177
184
|
)
|
|
178
|
-
|
|
185
|
+
self.stop_server(port=target_port, timeout=5)
|
|
186
|
+
# Continue with starting a new server
|
|
179
187
|
|
|
180
188
|
# Ensure dependencies are available
|
|
181
189
|
deps_ok, error_msg = self.ensure_dependencies()
|
|
@@ -459,14 +467,37 @@ class SocketIOManager(ISocketIOManager):
|
|
|
459
467
|
Returns:
|
|
460
468
|
Available port number
|
|
461
469
|
"""
|
|
462
|
-
#
|
|
470
|
+
# First check if our Socket.IO server is already running on the preferred port
|
|
471
|
+
if self.is_server_running(preferred_port):
|
|
472
|
+
# Check if it's healthy
|
|
473
|
+
if self.wait_for_server(preferred_port, timeout=2):
|
|
474
|
+
self.logger.info(
|
|
475
|
+
f"Healthy Socket.IO server already running on port {preferred_port}"
|
|
476
|
+
)
|
|
477
|
+
return preferred_port
|
|
478
|
+
self.logger.warning(
|
|
479
|
+
f"Socket.IO server on port {preferred_port} not responding, will try to restart"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Try preferred port first if available
|
|
463
483
|
if self.port_manager.is_port_available(preferred_port):
|
|
464
484
|
return preferred_port
|
|
465
485
|
|
|
466
|
-
# Find alternative port
|
|
467
|
-
available_port = self.port_manager.
|
|
468
|
-
|
|
469
|
-
|
|
486
|
+
# Find alternative port using the correct method name
|
|
487
|
+
available_port = self.port_manager.find_available_port(
|
|
488
|
+
preferred_port=preferred_port, reclaim=True
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
if available_port:
|
|
492
|
+
self.logger.info(
|
|
493
|
+
f"Port {preferred_port} unavailable, using {available_port}"
|
|
494
|
+
)
|
|
495
|
+
return available_port
|
|
496
|
+
# If no port found, raise an error
|
|
497
|
+
raise RuntimeError(
|
|
498
|
+
f"No available ports in range {self.port_manager.PORT_RANGE.start}-"
|
|
499
|
+
f"{self.port_manager.PORT_RANGE.stop-1}"
|
|
500
|
+
)
|
|
470
501
|
|
|
471
502
|
def ensure_dependencies(self) -> Tuple[bool, Optional[str]]:
|
|
472
503
|
"""
|
|
@@ -30,7 +30,7 @@ For backward compatibility, legacy classes are still available:
|
|
|
30
30
|
"""
|
|
31
31
|
|
|
32
32
|
# Re-export all components from the modular implementation
|
|
33
|
-
from .monitoring import ( # noqa: F401
|
|
33
|
+
from .monitoring import ( # noqa: F401
|
|
34
34
|
AdvancedHealthMonitor,
|
|
35
35
|
HealthChecker,
|
|
36
36
|
HealthCheckResult,
|
|
@@ -59,6 +59,7 @@ class EventType(Enum):
|
|
|
59
59
|
PERFORMANCE = "performance" # Performance metrics
|
|
60
60
|
CLAUDE = "claude" # Claude process events
|
|
61
61
|
TEST = "test" # Test events
|
|
62
|
+
CODE = "code" # Code analysis events
|
|
62
63
|
TOOL = "tool" # Tool events
|
|
63
64
|
SUBAGENT = "subagent" # Subagent events
|
|
64
65
|
|
|
@@ -351,6 +352,41 @@ class EventNormalizer:
|
|
|
351
352
|
event_type.value if isinstance(event_type, EventType) else event_type
|
|
352
353
|
), subtype
|
|
353
354
|
|
|
355
|
+
# Handle colon-separated event names (e.g., "code:analysis:queued", "code:progress")
|
|
356
|
+
# These are commonly used by the code analysis system
|
|
357
|
+
if ":" in event_name:
|
|
358
|
+
parts = event_name.split(":", 2) # Split into max 3 parts
|
|
359
|
+
if len(parts) >= 2:
|
|
360
|
+
type_part = parts[0].lower()
|
|
361
|
+
# For events like "code:analysis:queued", combine the last parts as subtype
|
|
362
|
+
# Replace colons with underscores for clean subtypes
|
|
363
|
+
if len(parts) == 3:
|
|
364
|
+
subtype_part = f"{parts[1]}_{parts[2]}"
|
|
365
|
+
else:
|
|
366
|
+
subtype_part = parts[1].replace(":", "_")
|
|
367
|
+
|
|
368
|
+
# Map the type part to known types
|
|
369
|
+
if type_part in [
|
|
370
|
+
"code", # Code analysis events
|
|
371
|
+
"hook",
|
|
372
|
+
"session",
|
|
373
|
+
"file",
|
|
374
|
+
"system",
|
|
375
|
+
"connection",
|
|
376
|
+
"memory",
|
|
377
|
+
"git",
|
|
378
|
+
"todo",
|
|
379
|
+
"ticket",
|
|
380
|
+
"agent",
|
|
381
|
+
"claude",
|
|
382
|
+
"error",
|
|
383
|
+
"performance",
|
|
384
|
+
"test",
|
|
385
|
+
"tool",
|
|
386
|
+
"subagent",
|
|
387
|
+
]:
|
|
388
|
+
return type_part, subtype_part
|
|
389
|
+
|
|
354
390
|
# Handle dotted event names (e.g., "connection.status", "session.started")
|
|
355
391
|
if "." in event_name:
|
|
356
392
|
parts = event_name.split(".", 1)
|
|
@@ -443,6 +479,34 @@ class EventNormalizer:
|
|
|
443
479
|
return EventType.MEMORY.value, "injected"
|
|
444
480
|
return EventType.MEMORY.value, "generic"
|
|
445
481
|
|
|
482
|
+
# Code analysis events - using underscores for clean subtypes
|
|
483
|
+
if "code" in event_lower:
|
|
484
|
+
if "analysis" in event_lower:
|
|
485
|
+
if "queue" in event_lower:
|
|
486
|
+
return EventType.CODE.value, "analysis_queued"
|
|
487
|
+
if "start" in event_lower:
|
|
488
|
+
return EventType.CODE.value, "analysis_start"
|
|
489
|
+
if "complete" in event_lower:
|
|
490
|
+
return EventType.CODE.value, "analysis_complete"
|
|
491
|
+
if "error" in event_lower:
|
|
492
|
+
return EventType.CODE.value, "analysis_error"
|
|
493
|
+
if "cancel" in event_lower:
|
|
494
|
+
return EventType.CODE.value, "analysis_cancelled"
|
|
495
|
+
return EventType.CODE.value, "analysis_generic"
|
|
496
|
+
if "progress" in event_lower:
|
|
497
|
+
return EventType.CODE.value, "progress"
|
|
498
|
+
if "file" in event_lower:
|
|
499
|
+
if "discovered" in event_lower:
|
|
500
|
+
return EventType.CODE.value, "file_discovered"
|
|
501
|
+
if "analyzed" in event_lower:
|
|
502
|
+
return EventType.CODE.value, "file_analyzed"
|
|
503
|
+
return EventType.CODE.value, "file_complete"
|
|
504
|
+
if "directory" in event_lower:
|
|
505
|
+
return EventType.CODE.value, "directory_discovered"
|
|
506
|
+
if "node" in event_lower:
|
|
507
|
+
return EventType.CODE.value, "node_found"
|
|
508
|
+
return EventType.CODE.value, "generic"
|
|
509
|
+
|
|
446
510
|
# Default to unknown with lowercase subtype
|
|
447
511
|
return "unknown", event_name.lower() if event_name else ""
|
|
448
512
|
|
|
@@ -12,11 +12,15 @@ DESIGN DECISIONS:
|
|
|
12
12
|
- Stream events in real-time to all connected clients
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
|
+
import asyncio
|
|
15
16
|
import uuid
|
|
17
|
+
from pathlib import Path
|
|
16
18
|
from typing import Any, Dict
|
|
17
19
|
|
|
18
20
|
from ....core.logging_config import get_logger
|
|
19
21
|
from ....dashboard.analysis_runner import CodeAnalysisRunner
|
|
22
|
+
from ....tools.code_tree_analyzer import CodeTreeAnalyzer
|
|
23
|
+
from ....tools.code_tree_events import CodeTreeEventEmitter
|
|
20
24
|
from .base import BaseEventHandler
|
|
21
25
|
|
|
22
26
|
|
|
@@ -36,6 +40,7 @@ class CodeAnalysisEventHandler(BaseEventHandler):
|
|
|
36
40
|
super().__init__(server)
|
|
37
41
|
self.logger = get_logger(__name__)
|
|
38
42
|
self.analysis_runner = None
|
|
43
|
+
self.code_analyzer = None # For lazy loading operations
|
|
39
44
|
|
|
40
45
|
def initialize(self):
|
|
41
46
|
"""Initialize the analysis runner."""
|
|
@@ -58,9 +63,14 @@ class CodeAnalysisEventHandler(BaseEventHandler):
|
|
|
58
63
|
Dictionary mapping event names to handler methods
|
|
59
64
|
"""
|
|
60
65
|
return {
|
|
66
|
+
# Legacy full analysis
|
|
61
67
|
"code:analyze:request": self.handle_analyze_request,
|
|
62
68
|
"code:analyze:cancel": self.handle_cancel_request,
|
|
63
69
|
"code:analyze:status": self.handle_status_request,
|
|
70
|
+
# Lazy loading operations
|
|
71
|
+
"code:discover:top_level": self.handle_discover_top_level,
|
|
72
|
+
"code:discover:directory": self.handle_discover_directory,
|
|
73
|
+
"code:analyze:file": self.handle_analyze_file,
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
def register_events(self) -> None:
|
|
@@ -168,3 +178,442 @@ class CodeAnalysisEventHandler(BaseEventHandler):
|
|
|
168
178
|
|
|
169
179
|
# Send status to requesting client
|
|
170
180
|
await self.server.sio.emit("code:analysis:status", status, room=sid)
|
|
181
|
+
|
|
182
|
+
async def handle_discover_top_level(self, sid: str, data: Dict[str, Any]):
|
|
183
|
+
"""Handle top-level directory discovery request for lazy loading.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
sid: Socket ID of the requesting client
|
|
187
|
+
data: Request data containing path and options
|
|
188
|
+
"""
|
|
189
|
+
self.logger.info(f"Top-level discovery requested from {sid}: {data}")
|
|
190
|
+
|
|
191
|
+
# Get path - this MUST be an absolute path from the frontend
|
|
192
|
+
path = data.get("path")
|
|
193
|
+
if not path:
|
|
194
|
+
await self.server.core.sio.emit(
|
|
195
|
+
"code:analysis:error",
|
|
196
|
+
{
|
|
197
|
+
"error": "Path is required for top-level discovery",
|
|
198
|
+
"request_id": data.get("request_id"),
|
|
199
|
+
},
|
|
200
|
+
room=sid,
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# CRITICAL: Never use "." or allow relative paths
|
|
205
|
+
# The frontend must send the absolute working directory
|
|
206
|
+
if path in (".", "..", "/") or not Path(path).is_absolute():
|
|
207
|
+
self.logger.warning(f"Invalid path for discovery: {path}")
|
|
208
|
+
await self.server.core.sio.emit(
|
|
209
|
+
"code:analysis:error",
|
|
210
|
+
{
|
|
211
|
+
"error": f"Invalid path for discovery: {path}. Must be an absolute path.",
|
|
212
|
+
"request_id": data.get("request_id"),
|
|
213
|
+
"path": path,
|
|
214
|
+
},
|
|
215
|
+
room=sid,
|
|
216
|
+
)
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# SECURITY: Validate the requested path
|
|
220
|
+
# Allow access to the explicitly chosen working directory and its subdirectories
|
|
221
|
+
requested_path = Path(path).absolute()
|
|
222
|
+
|
|
223
|
+
# For now, we trust the frontend to send valid paths
|
|
224
|
+
# In production, you might want to maintain a server-side list of allowed directories
|
|
225
|
+
# or implement a more sophisticated permission system
|
|
226
|
+
|
|
227
|
+
# Basic sanity checks are done below after creating the Path object
|
|
228
|
+
|
|
229
|
+
ignore_patterns = data.get("ignore_patterns", [])
|
|
230
|
+
request_id = data.get("request_id")
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
# Create analyzer if needed
|
|
234
|
+
if not self.code_analyzer:
|
|
235
|
+
# Create a custom emitter that sends to Socket.IO
|
|
236
|
+
emitter = CodeTreeEventEmitter(use_stdout=False)
|
|
237
|
+
# Override emit method to send to Socket.IO
|
|
238
|
+
original_emit = emitter.emit
|
|
239
|
+
|
|
240
|
+
def socket_emit(
|
|
241
|
+
event_type: str, event_data: Dict[str, Any], batch: bool = False
|
|
242
|
+
):
|
|
243
|
+
# Keep the original event format with colons - frontend expects this!
|
|
244
|
+
# The frontend listens for 'code:directory:discovered' not 'code.directory.discovered'
|
|
245
|
+
|
|
246
|
+
# Special handling for 'info' events - they should be passed through directly
|
|
247
|
+
if event_type == "info":
|
|
248
|
+
# INFO events for granular tracking
|
|
249
|
+
loop = asyncio.get_event_loop()
|
|
250
|
+
loop.create_task(
|
|
251
|
+
self.server.core.sio.emit(
|
|
252
|
+
"info", {"request_id": request_id, **event_data}
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
# Regular code analysis events
|
|
257
|
+
loop = asyncio.get_event_loop()
|
|
258
|
+
loop.create_task(
|
|
259
|
+
self.server.core.sio.emit(
|
|
260
|
+
event_type, {"request_id": request_id, **event_data}
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
# Call original for stats tracking
|
|
264
|
+
original_emit(event_type, event_data, batch)
|
|
265
|
+
|
|
266
|
+
emitter.emit = socket_emit
|
|
267
|
+
# Initialize CodeTreeAnalyzer with emitter keyword argument
|
|
268
|
+
self.logger.info("Creating CodeTreeAnalyzer")
|
|
269
|
+
self.code_analyzer = CodeTreeAnalyzer(emitter=emitter)
|
|
270
|
+
|
|
271
|
+
# Use the provided path as-is - the frontend sends the absolute path
|
|
272
|
+
# Make sure we're using an absolute path
|
|
273
|
+
directory = Path(path)
|
|
274
|
+
|
|
275
|
+
# Validate that the path exists and is a directory
|
|
276
|
+
if not directory.exists():
|
|
277
|
+
await self.server.core.sio.emit(
|
|
278
|
+
"code:analysis:error",
|
|
279
|
+
{
|
|
280
|
+
"request_id": request_id,
|
|
281
|
+
"path": path,
|
|
282
|
+
"error": f"Directory does not exist: {path}",
|
|
283
|
+
},
|
|
284
|
+
room=sid,
|
|
285
|
+
)
|
|
286
|
+
return
|
|
287
|
+
|
|
288
|
+
if not directory.is_dir():
|
|
289
|
+
await self.server.core.sio.emit(
|
|
290
|
+
"code:analysis:error",
|
|
291
|
+
{
|
|
292
|
+
"request_id": request_id,
|
|
293
|
+
"path": path,
|
|
294
|
+
"error": f"Path is not a directory: {path}",
|
|
295
|
+
},
|
|
296
|
+
room=sid,
|
|
297
|
+
)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
# Log what we're actually discovering
|
|
301
|
+
self.logger.info(
|
|
302
|
+
f"Discovering top-level contents of: {directory.absolute()}"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
result = self.code_analyzer.discover_top_level(directory, ignore_patterns)
|
|
306
|
+
|
|
307
|
+
# Send result to client with correct event name for top level discovery
|
|
308
|
+
await self.server.core.sio.emit(
|
|
309
|
+
"code:top_level:discovered",
|
|
310
|
+
{
|
|
311
|
+
"request_id": request_id,
|
|
312
|
+
"path": str(directory),
|
|
313
|
+
"items": result.get("children", []),
|
|
314
|
+
"stats": {
|
|
315
|
+
"files": len(
|
|
316
|
+
[
|
|
317
|
+
c
|
|
318
|
+
for c in result.get("children", [])
|
|
319
|
+
if c.get("type") == "file"
|
|
320
|
+
]
|
|
321
|
+
),
|
|
322
|
+
"directories": len(
|
|
323
|
+
[
|
|
324
|
+
c
|
|
325
|
+
for c in result.get("children", [])
|
|
326
|
+
if c.get("type") == "directory"
|
|
327
|
+
]
|
|
328
|
+
),
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
room=sid,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
self.logger.error(f"Error discovering top level: {e}")
|
|
336
|
+
await self.server.core.sio.emit(
|
|
337
|
+
"code:analysis:error",
|
|
338
|
+
{
|
|
339
|
+
"request_id": request_id,
|
|
340
|
+
"path": path,
|
|
341
|
+
"error": str(e),
|
|
342
|
+
},
|
|
343
|
+
room=sid,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
async def handle_discover_directory(self, sid: str, data: Dict[str, Any]):
|
|
347
|
+
"""Handle directory discovery request for lazy loading.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
sid: Socket ID of the requesting client
|
|
351
|
+
data: Request data containing directory path
|
|
352
|
+
"""
|
|
353
|
+
self.logger.info(f"Directory discovery requested from {sid}: {data}")
|
|
354
|
+
|
|
355
|
+
path = data.get("path")
|
|
356
|
+
ignore_patterns = data.get("ignore_patterns", [])
|
|
357
|
+
request_id = data.get("request_id")
|
|
358
|
+
|
|
359
|
+
if not path:
|
|
360
|
+
await self.server.core.sio.emit(
|
|
361
|
+
"code:analysis:error",
|
|
362
|
+
{
|
|
363
|
+
"request_id": request_id,
|
|
364
|
+
"error": "Path is required",
|
|
365
|
+
},
|
|
366
|
+
room=sid,
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
# CRITICAL SECURITY FIX: Add path validation to prevent filesystem traversal
|
|
371
|
+
# The same validation logic as handle_discover_top_level
|
|
372
|
+
if path in (".", "..", "/") or not Path(path).is_absolute():
|
|
373
|
+
self.logger.warning(f"Invalid path for directory discovery: {path}")
|
|
374
|
+
await self.server.core.sio.emit(
|
|
375
|
+
"code:analysis:error",
|
|
376
|
+
{
|
|
377
|
+
"error": f"Invalid path for discovery: {path}. Must be an absolute path.",
|
|
378
|
+
"request_id": request_id,
|
|
379
|
+
"path": path,
|
|
380
|
+
},
|
|
381
|
+
room=sid,
|
|
382
|
+
)
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# SECURITY: Validate the requested path
|
|
386
|
+
# Allow access to the explicitly chosen working directory and its subdirectories
|
|
387
|
+
requested_path = Path(path).absolute()
|
|
388
|
+
|
|
389
|
+
# For now, we trust the frontend to send valid paths
|
|
390
|
+
# In production, you might want to maintain a server-side list of allowed directories
|
|
391
|
+
# or implement a more sophisticated permission system
|
|
392
|
+
|
|
393
|
+
# Basic sanity checks
|
|
394
|
+
if not requested_path.exists():
|
|
395
|
+
self.logger.warning(f"Path does not exist: {path}")
|
|
396
|
+
await self.server.core.sio.emit(
|
|
397
|
+
"code:analysis:error",
|
|
398
|
+
{
|
|
399
|
+
"error": f"Path does not exist: {path}",
|
|
400
|
+
"request_id": request_id,
|
|
401
|
+
"path": path,
|
|
402
|
+
},
|
|
403
|
+
room=sid,
|
|
404
|
+
)
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
if not requested_path.is_dir():
|
|
408
|
+
self.logger.warning(f"Path is not a directory: {path}")
|
|
409
|
+
await self.server.core.sio.emit(
|
|
410
|
+
"code:analysis:error",
|
|
411
|
+
{
|
|
412
|
+
"error": f"Path is not a directory: {path}",
|
|
413
|
+
"request_id": request_id,
|
|
414
|
+
"path": path,
|
|
415
|
+
},
|
|
416
|
+
room=sid,
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
# Ensure analyzer exists
|
|
422
|
+
if not self.code_analyzer:
|
|
423
|
+
emitter = CodeTreeEventEmitter(use_stdout=False)
|
|
424
|
+
# Override emit method to send to Socket.IO
|
|
425
|
+
original_emit = emitter.emit
|
|
426
|
+
|
|
427
|
+
def socket_emit(
|
|
428
|
+
event_type: str, event_data: Dict[str, Any], batch: bool = False
|
|
429
|
+
):
|
|
430
|
+
# Keep the original event format with colons - frontend expects this!
|
|
431
|
+
# The frontend listens for 'code:directory:discovered' not 'code.directory.discovered'
|
|
432
|
+
|
|
433
|
+
# Special handling for 'info' events - they should be passed through directly
|
|
434
|
+
if event_type == "info":
|
|
435
|
+
# INFO events for granular tracking
|
|
436
|
+
loop = asyncio.get_event_loop()
|
|
437
|
+
loop.create_task(
|
|
438
|
+
self.server.core.sio.emit(
|
|
439
|
+
"info", {"request_id": request_id, **event_data}
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
else:
|
|
443
|
+
# Regular code analysis events
|
|
444
|
+
loop = asyncio.get_event_loop()
|
|
445
|
+
loop.create_task(
|
|
446
|
+
self.server.core.sio.emit(
|
|
447
|
+
event_type, {"request_id": request_id, **event_data}
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
original_emit(event_type, event_data, batch)
|
|
451
|
+
|
|
452
|
+
emitter.emit = socket_emit
|
|
453
|
+
# Initialize CodeTreeAnalyzer with emitter keyword argument
|
|
454
|
+
self.logger.info("Creating CodeTreeAnalyzer")
|
|
455
|
+
self.code_analyzer = CodeTreeAnalyzer(emitter=emitter)
|
|
456
|
+
|
|
457
|
+
# Discover directory
|
|
458
|
+
result = self.code_analyzer.discover_directory(path, ignore_patterns)
|
|
459
|
+
|
|
460
|
+
# Log what we're sending
|
|
461
|
+
self.logger.info(
|
|
462
|
+
f"Discovery result for {path}: {len(result.get('children', []))} children found"
|
|
463
|
+
)
|
|
464
|
+
self.logger.debug(f"Full result: {result}")
|
|
465
|
+
|
|
466
|
+
# Send result with correct event name (using colons, not dots!)
|
|
467
|
+
await self.server.core.sio.emit(
|
|
468
|
+
"code:directory:discovered",
|
|
469
|
+
{
|
|
470
|
+
"request_id": request_id,
|
|
471
|
+
"path": path,
|
|
472
|
+
**result,
|
|
473
|
+
},
|
|
474
|
+
room=sid,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
except Exception as e:
|
|
478
|
+
self.logger.error(f"Error discovering directory {path}: {e}")
|
|
479
|
+
await self.server.core.sio.emit(
|
|
480
|
+
"code:analysis:error",
|
|
481
|
+
{
|
|
482
|
+
"request_id": request_id,
|
|
483
|
+
"path": path,
|
|
484
|
+
"error": str(e),
|
|
485
|
+
},
|
|
486
|
+
room=sid,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
async def handle_analyze_file(self, sid: str, data: Dict[str, Any]):
|
|
490
|
+
"""Handle file analysis request for lazy loading.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
sid: Socket ID of the requesting client
|
|
494
|
+
data: Request data containing file path
|
|
495
|
+
"""
|
|
496
|
+
self.logger.info(f"File analysis requested from {sid}: {data}")
|
|
497
|
+
|
|
498
|
+
path = data.get("path")
|
|
499
|
+
request_id = data.get("request_id")
|
|
500
|
+
show_hidden_files = data.get("show_hidden_files", False)
|
|
501
|
+
|
|
502
|
+
if not path:
|
|
503
|
+
await self.server.core.sio.emit(
|
|
504
|
+
"code:analysis:error",
|
|
505
|
+
{
|
|
506
|
+
"request_id": request_id,
|
|
507
|
+
"error": "Path is required",
|
|
508
|
+
},
|
|
509
|
+
room=sid,
|
|
510
|
+
)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# CRITICAL SECURITY FIX: Add path validation to prevent filesystem traversal
|
|
514
|
+
if path in (".", "..", "/") or not Path(path).is_absolute():
|
|
515
|
+
self.logger.warning(f"Invalid path for file analysis: {path}")
|
|
516
|
+
await self.server.core.sio.emit(
|
|
517
|
+
"code:analysis:error",
|
|
518
|
+
{
|
|
519
|
+
"error": f"Invalid path for analysis: {path}. Must be an absolute path.",
|
|
520
|
+
"request_id": request_id,
|
|
521
|
+
"path": path,
|
|
522
|
+
},
|
|
523
|
+
room=sid,
|
|
524
|
+
)
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
# SECURITY: Validate the requested file path
|
|
528
|
+
# Allow access to files within the explicitly chosen working directory
|
|
529
|
+
requested_path = Path(path).absolute()
|
|
530
|
+
|
|
531
|
+
# Basic sanity checks
|
|
532
|
+
if not requested_path.exists():
|
|
533
|
+
self.logger.warning(f"File does not exist: {path}")
|
|
534
|
+
await self.server.core.sio.emit(
|
|
535
|
+
"code:analysis:error",
|
|
536
|
+
{
|
|
537
|
+
"error": f"File does not exist: {path}",
|
|
538
|
+
"request_id": request_id,
|
|
539
|
+
"path": path,
|
|
540
|
+
},
|
|
541
|
+
room=sid,
|
|
542
|
+
)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
if not requested_path.is_file():
|
|
546
|
+
self.logger.warning(f"Path is not a file: {path}")
|
|
547
|
+
await self.server.core.sio.emit(
|
|
548
|
+
"code:analysis:error",
|
|
549
|
+
{
|
|
550
|
+
"error": f"Path is not a file: {path}",
|
|
551
|
+
"request_id": request_id,
|
|
552
|
+
"path": path,
|
|
553
|
+
},
|
|
554
|
+
room=sid,
|
|
555
|
+
)
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
try:
|
|
559
|
+
# Ensure analyzer exists
|
|
560
|
+
if not self.code_analyzer:
|
|
561
|
+
emitter = CodeTreeEventEmitter(use_stdout=False)
|
|
562
|
+
# Override emit method to send to Socket.IO
|
|
563
|
+
original_emit = emitter.emit
|
|
564
|
+
|
|
565
|
+
def socket_emit(
|
|
566
|
+
event_type: str, event_data: Dict[str, Any], batch: bool = False
|
|
567
|
+
):
|
|
568
|
+
# Keep the original event format with colons - frontend expects this!
|
|
569
|
+
# The frontend listens for 'code:file:analyzed' not 'code.file.analyzed'
|
|
570
|
+
|
|
571
|
+
# Special handling for 'info' events - they should be passed through directly
|
|
572
|
+
if event_type == "info":
|
|
573
|
+
# INFO events for granular tracking
|
|
574
|
+
loop = asyncio.get_event_loop()
|
|
575
|
+
loop.create_task(
|
|
576
|
+
self.server.core.sio.emit(
|
|
577
|
+
"info", {"request_id": request_id, **event_data}
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
# Regular code analysis events
|
|
582
|
+
loop = asyncio.get_event_loop()
|
|
583
|
+
loop.create_task(
|
|
584
|
+
self.server.core.sio.emit(
|
|
585
|
+
event_type, {"request_id": request_id, **event_data}
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
original_emit(event_type, event_data, batch)
|
|
589
|
+
|
|
590
|
+
emitter.emit = socket_emit
|
|
591
|
+
# Initialize CodeTreeAnalyzer with emitter keyword argument
|
|
592
|
+
self.logger.info("Creating CodeTreeAnalyzer")
|
|
593
|
+
self.code_analyzer = CodeTreeAnalyzer(emitter=emitter)
|
|
594
|
+
|
|
595
|
+
# Analyze file
|
|
596
|
+
result = self.code_analyzer.analyze_file(path)
|
|
597
|
+
|
|
598
|
+
# Send result with correct event name (using colons, not dots!)
|
|
599
|
+
await self.server.core.sio.emit(
|
|
600
|
+
"code:file:analyzed",
|
|
601
|
+
{
|
|
602
|
+
"request_id": request_id,
|
|
603
|
+
"path": path,
|
|
604
|
+
**result,
|
|
605
|
+
},
|
|
606
|
+
room=sid,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
except Exception as e:
|
|
610
|
+
self.logger.error(f"Error analyzing file {path}: {e}")
|
|
611
|
+
await self.server.core.sio.emit(
|
|
612
|
+
"code:analysis:error",
|
|
613
|
+
{
|
|
614
|
+
"request_id": request_id,
|
|
615
|
+
"path": path,
|
|
616
|
+
"error": str(e),
|
|
617
|
+
},
|
|
618
|
+
room=sid,
|
|
619
|
+
)
|