synaptiq 0.3.0__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.
Files changed (51) hide show
  1. synaptiq/__init__.py +3 -0
  2. synaptiq/cli/__init__.py +1 -0
  3. synaptiq/cli/main.py +475 -0
  4. synaptiq/config/__init__.py +13 -0
  5. synaptiq/config/ignore.py +135 -0
  6. synaptiq/config/languages.py +27 -0
  7. synaptiq/core/__init__.py +1 -0
  8. synaptiq/core/daemon/__init__.py +1 -0
  9. synaptiq/core/daemon/lock.py +152 -0
  10. synaptiq/core/daemon/socket_client.py +110 -0
  11. synaptiq/core/daemon/socket_server.py +124 -0
  12. synaptiq/core/diff.py +246 -0
  13. synaptiq/core/embeddings/__init__.py +1 -0
  14. synaptiq/core/embeddings/embedder.py +87 -0
  15. synaptiq/core/embeddings/text.py +206 -0
  16. synaptiq/core/graph/__init__.py +1 -0
  17. synaptiq/core/graph/graph.py +175 -0
  18. synaptiq/core/graph/model.py +97 -0
  19. synaptiq/core/ingestion/__init__.py +1 -0
  20. synaptiq/core/ingestion/calls.py +360 -0
  21. synaptiq/core/ingestion/community.py +193 -0
  22. synaptiq/core/ingestion/coupling.py +224 -0
  23. synaptiq/core/ingestion/dead_code.py +345 -0
  24. synaptiq/core/ingestion/heritage.py +141 -0
  25. synaptiq/core/ingestion/imports.py +256 -0
  26. synaptiq/core/ingestion/parser_phase.py +216 -0
  27. synaptiq/core/ingestion/pipeline.py +214 -0
  28. synaptiq/core/ingestion/processes.py +315 -0
  29. synaptiq/core/ingestion/structure.py +110 -0
  30. synaptiq/core/ingestion/symbol_lookup.py +139 -0
  31. synaptiq/core/ingestion/types.py +139 -0
  32. synaptiq/core/ingestion/walker.py +123 -0
  33. synaptiq/core/ingestion/watcher.py +153 -0
  34. synaptiq/core/parsers/__init__.py +1 -0
  35. synaptiq/core/parsers/base.py +70 -0
  36. synaptiq/core/parsers/python_lang.py +589 -0
  37. synaptiq/core/parsers/typescript.py +656 -0
  38. synaptiq/core/search/__init__.py +1 -0
  39. synaptiq/core/search/hybrid.py +102 -0
  40. synaptiq/core/storage/__init__.py +1 -0
  41. synaptiq/core/storage/base.py +122 -0
  42. synaptiq/core/storage/kuzu_backend.py +843 -0
  43. synaptiq/mcp/__init__.py +1 -0
  44. synaptiq/mcp/resources.py +150 -0
  45. synaptiq/mcp/server.py +297 -0
  46. synaptiq/mcp/tools.py +336 -0
  47. synaptiq/py.typed +0 -0
  48. synaptiq-0.3.0.dist-info/METADATA +588 -0
  49. synaptiq-0.3.0.dist-info/RECORD +51 -0
  50. synaptiq-0.3.0.dist-info/WHEEL +4 -0
  51. synaptiq-0.3.0.dist-info/entry_points.txt +3 -0
synaptiq/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Synaptiq — Graph-powered code intelligence engine."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1 @@
1
+
synaptiq/cli/main.py ADDED
@@ -0,0 +1,475 @@
1
+ """Synaptiq CLI — Graph-powered code intelligence engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.progress import Progress, SpinnerColumn, TextColumn
14
+
15
+ from synaptiq import __version__
16
+
17
+ console = Console()
18
+
19
+ def _load_storage(repo_path: Path | None = None) -> "KuzuBackend": # noqa: F821
20
+ """Load the KuzuDB backend for the given or current repo."""
21
+ from synaptiq.core.storage.kuzu_backend import KuzuBackend
22
+
23
+ target = (repo_path or Path.cwd()).resolve()
24
+ db_path = target / ".synaptiq" / "kuzu"
25
+ if not db_path.exists():
26
+ console.print(
27
+ f"[red]Error:[/red] No index found at {target}. Run 'synaptiq analyze' first."
28
+ )
29
+ raise typer.Exit(code=1)
30
+
31
+ storage = KuzuBackend()
32
+ storage.initialize(db_path, read_only=True)
33
+ return storage
34
+
35
+ app = typer.Typer(
36
+ name="synaptiq",
37
+ help="Synaptiq — Graph-powered code intelligence engine.",
38
+ no_args_is_help=True,
39
+ )
40
+
41
+ def _version_callback(value: bool) -> None:
42
+ """Print the version and exit."""
43
+ if value:
44
+ console.print(f"Synaptiq v{__version__}")
45
+ raise typer.Exit()
46
+
47
+ @app.callback()
48
+ def main(
49
+ version: Optional[bool] = typer.Option( # noqa: N803
50
+ None,
51
+ "--version",
52
+ "-v",
53
+ help="Show version and exit.",
54
+ callback=_version_callback,
55
+ is_eager=True,
56
+ ),
57
+ ) -> None:
58
+ """Synaptiq — Graph-powered code intelligence engine."""
59
+
60
+ @app.command()
61
+ def analyze(
62
+ path: Path = typer.Argument(Path("."), help="Path to the repository to index."),
63
+ full: bool = typer.Option(False, "--full", help="Perform a full re-index."),
64
+ ) -> None:
65
+ """Index a repository into a knowledge graph."""
66
+ from synaptiq.core.ingestion.pipeline import PipelineResult, run_pipeline
67
+ from synaptiq.core.storage.kuzu_backend import KuzuBackend
68
+
69
+ repo_path = path.resolve()
70
+ if not repo_path.is_dir():
71
+ console.print(f"[red]Error:[/red] {repo_path} is not a directory.")
72
+ raise typer.Exit(code=1)
73
+
74
+ console.print(f"[bold]Indexing[/bold] {repo_path}")
75
+
76
+ data_dir = repo_path / ".synaptiq"
77
+ data_dir.mkdir(parents=True, exist_ok=True)
78
+ db_path = data_dir / "kuzu"
79
+
80
+ storage = KuzuBackend()
81
+ storage.initialize(db_path)
82
+
83
+ result: PipelineResult | None = None
84
+ with Progress(
85
+ SpinnerColumn(),
86
+ TextColumn("[progress.description]{task.description}"),
87
+ console=console,
88
+ transient=True,
89
+ ) as progress:
90
+ task = progress.add_task("Starting...", total=None)
91
+
92
+ def on_progress(phase: str, pct: float) -> None:
93
+ progress.update(task, description=f"{phase} ({pct:.0%})")
94
+
95
+ _, result = run_pipeline(
96
+ repo_path=repo_path,
97
+ storage=storage,
98
+ full=full,
99
+ progress_callback=on_progress,
100
+ )
101
+
102
+ meta = {
103
+ "version": __version__,
104
+ "name": repo_path.name,
105
+ "path": str(repo_path),
106
+ "stats": {
107
+ "files": result.files,
108
+ "symbols": result.symbols,
109
+ "relationships": result.relationships,
110
+ "clusters": result.clusters,
111
+ "flows": result.processes,
112
+ "dead_code": result.dead_code,
113
+ "coupled_pairs": result.coupled_pairs,
114
+ },
115
+ "last_indexed_at": datetime.now(tz=timezone.utc).isoformat(),
116
+ }
117
+ meta_path = data_dir / "meta.json"
118
+ meta_path.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8")
119
+
120
+ console.print()
121
+ console.print("[bold green]Indexing complete.[/bold green]")
122
+ console.print(f" Files: {result.files}")
123
+ console.print(f" Symbols: {result.symbols}")
124
+ console.print(f" Relationships: {result.relationships}")
125
+ if result.clusters > 0:
126
+ console.print(f" Clusters: {result.clusters}")
127
+ if result.processes > 0:
128
+ console.print(f" Flows: {result.processes}")
129
+ if result.dead_code > 0:
130
+ console.print(f" Dead code: {result.dead_code}")
131
+ if result.coupled_pairs > 0:
132
+ console.print(f" Coupled pairs: {result.coupled_pairs}")
133
+ console.print(f" Duration: {result.duration_seconds:.2f}s")
134
+
135
+ storage.close()
136
+
137
+ @app.command()
138
+ def status() -> None:
139
+ """Show index status for current repository."""
140
+ repo_path = Path.cwd().resolve()
141
+ meta_path = repo_path / ".synaptiq" / "meta.json"
142
+
143
+ if not meta_path.exists():
144
+ console.print(
145
+ f"[red]Error:[/red] No index found at {repo_path}. Run 'synaptiq analyze' first."
146
+ )
147
+ raise typer.Exit(code=1)
148
+
149
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
150
+ stats = meta.get("stats", {})
151
+
152
+ console.print(f"[bold]Index status for[/bold] {repo_path}")
153
+ console.print(f" Version: {meta.get('version', '?')}")
154
+ console.print(f" Last indexed: {meta.get('last_indexed_at', '?')}")
155
+ console.print(f" Files: {stats.get('files', '?')}")
156
+ console.print(f" Symbols: {stats.get('symbols', '?')}")
157
+ console.print(f" Relationships: {stats.get('relationships', '?')}")
158
+
159
+ if stats.get("clusters", 0) > 0:
160
+ console.print(f" Clusters: {stats['clusters']}")
161
+ if stats.get("flows", 0) > 0:
162
+ console.print(f" Flows: {stats['flows']}")
163
+ if stats.get("dead_code", 0) > 0:
164
+ console.print(f" Dead code: {stats['dead_code']}")
165
+ if stats.get("coupled_pairs", 0) > 0:
166
+ console.print(f" Coupled pairs: {stats['coupled_pairs']}")
167
+
168
+ @app.command(name="list")
169
+ def list_repos() -> None:
170
+ """List all indexed repositories."""
171
+ from synaptiq.mcp.tools import handle_list_repos
172
+
173
+ result = handle_list_repos()
174
+ console.print(result)
175
+
176
+ @app.command()
177
+ def clean(
178
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
179
+ ) -> None:
180
+ """Delete index for current repository."""
181
+ repo_path = Path.cwd().resolve()
182
+ data_dir = repo_path / ".synaptiq"
183
+
184
+ if not data_dir.exists():
185
+ console.print(
186
+ f"[red]Error:[/red] No index found at {repo_path}. Nothing to clean."
187
+ )
188
+ raise typer.Exit(code=1)
189
+
190
+ if not force:
191
+ confirm = typer.confirm(f"Delete index at {data_dir}?")
192
+ if not confirm:
193
+ console.print("Aborted.")
194
+ raise typer.Exit()
195
+
196
+ shutil.rmtree(data_dir)
197
+ console.print(f"[green]Deleted[/green] {data_dir}")
198
+
199
+ @app.command()
200
+ def query(
201
+ q: str = typer.Argument(..., help="Search query for the knowledge graph."),
202
+ limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of results."),
203
+ ) -> None:
204
+ """Search the knowledge graph."""
205
+ from synaptiq.mcp.tools import handle_query
206
+
207
+ storage = _load_storage()
208
+ result = handle_query(storage, q, limit=limit)
209
+ console.print(result)
210
+ storage.close()
211
+
212
+ @app.command()
213
+ def context(
214
+ name: str = typer.Argument(..., help="Symbol name to inspect."),
215
+ ) -> None:
216
+ """Show 360-degree view of a symbol."""
217
+ from synaptiq.mcp.tools import handle_context
218
+
219
+ storage = _load_storage()
220
+ result = handle_context(storage, name)
221
+ console.print(result)
222
+ storage.close()
223
+
224
+ @app.command()
225
+ def impact(
226
+ target: str = typer.Argument(..., help="Symbol to analyze blast radius for."),
227
+ depth: int = typer.Option(3, "--depth", "-d", help="Traversal depth."),
228
+ ) -> None:
229
+ """Show blast radius of changing a symbol."""
230
+ from synaptiq.mcp.tools import handle_impact
231
+
232
+ storage = _load_storage()
233
+ result = handle_impact(storage, target, depth=depth)
234
+ console.print(result)
235
+ storage.close()
236
+
237
+ @app.command(name="dead-code")
238
+ def dead_code() -> None:
239
+ """List all detected dead code."""
240
+ from synaptiq.mcp.tools import handle_dead_code
241
+
242
+ storage = _load_storage()
243
+ result = handle_dead_code(storage)
244
+ console.print(result)
245
+ storage.close()
246
+
247
+ @app.command()
248
+ def cypher(
249
+ query: str = typer.Argument(..., help="Raw Cypher query to execute."),
250
+ ) -> None:
251
+ """Execute raw Cypher against the knowledge graph."""
252
+ from synaptiq.mcp.tools import handle_cypher
253
+
254
+ storage = _load_storage()
255
+ result = handle_cypher(storage, query)
256
+ console.print(result)
257
+ storage.close()
258
+
259
+ @app.command()
260
+ def setup(
261
+ claude: bool = typer.Option(False, "--claude", help="Configure MCP for Claude Code."),
262
+ cursor: bool = typer.Option(False, "--cursor", help="Configure MCP for Cursor."),
263
+ ) -> None:
264
+ """Configure MCP for Claude Code / Cursor."""
265
+ mcp_config = {
266
+ "command": "synaptiq",
267
+ "args": ["serve", "--watch"],
268
+ }
269
+
270
+ if claude or (not claude and not cursor):
271
+ console.print("[bold]Add to your Claude Code MCP config:[/bold]")
272
+ console.print(json.dumps({"synaptiq": mcp_config}, indent=2))
273
+
274
+ if cursor or (not claude and not cursor):
275
+ console.print("[bold]Add to your Cursor MCP config:[/bold]")
276
+ console.print(json.dumps({"synaptiq": mcp_config}, indent=2))
277
+
278
+ @app.command()
279
+ def watch() -> None:
280
+ """Watch mode — re-index on file changes."""
281
+ import asyncio
282
+
283
+ from synaptiq.core.ingestion.pipeline import run_pipeline
284
+ from synaptiq.core.ingestion.watcher import watch_repo
285
+ from synaptiq.core.storage.kuzu_backend import KuzuBackend
286
+
287
+ repo_path = Path.cwd().resolve()
288
+ data_dir = repo_path / ".synaptiq"
289
+ data_dir.mkdir(parents=True, exist_ok=True)
290
+ db_path = data_dir / "kuzu"
291
+
292
+ storage = KuzuBackend()
293
+ storage.initialize(db_path)
294
+
295
+ if not (data_dir / "meta.json").exists():
296
+ console.print("[bold]Running initial index...[/bold]")
297
+ run_pipeline(repo_path, storage, full=True)
298
+
299
+ console.print(f"[bold]Watching[/bold] {repo_path} for changes (Ctrl+C to stop)")
300
+
301
+ try:
302
+ asyncio.run(watch_repo(repo_path, storage))
303
+ except KeyboardInterrupt:
304
+ console.print("\n[bold]Watch stopped.[/bold]")
305
+ finally:
306
+ storage.close()
307
+
308
+ @app.command()
309
+ def diff(
310
+ branch_range: str = typer.Argument(
311
+ ..., help="Branch range for comparison (e.g. main..feature)."
312
+ ),
313
+ ) -> None:
314
+ """Structural branch comparison."""
315
+ from synaptiq.core.diff import diff_branches, format_diff
316
+
317
+ repo_path = Path.cwd().resolve()
318
+ try:
319
+ result = diff_branches(repo_path, branch_range)
320
+ except (ValueError, RuntimeError) as exc:
321
+ console.print(f"[red]Error:[/red] {exc}")
322
+ raise typer.Exit(code=1) from exc
323
+
324
+ console.print(format_diff(result))
325
+
326
+ @app.command()
327
+ def mcp() -> None:
328
+ """Start MCP server (stdio transport)."""
329
+ import asyncio
330
+
331
+ from synaptiq.mcp.server import main as mcp_main
332
+
333
+ asyncio.run(mcp_main())
334
+
335
+ @app.command()
336
+ def serve(
337
+ watch: bool = typer.Option(
338
+ False, "--watch", "-w", help="Enable file watching with auto-reindex."
339
+ ),
340
+ ) -> None:
341
+ """Start MCP server, optionally with live file watching."""
342
+ import asyncio
343
+ import sys
344
+
345
+ from synaptiq.mcp.server import main as mcp_main
346
+
347
+ if not watch:
348
+ asyncio.run(mcp_main())
349
+ return
350
+
351
+ from synaptiq.core.daemon.lock import LockManager
352
+
353
+ repo_path = Path.cwd().resolve()
354
+ data_dir = repo_path / ".synaptiq"
355
+ data_dir.mkdir(parents=True, exist_ok=True)
356
+
357
+ lock_mgr = LockManager(data_dir)
358
+ lock_info = lock_mgr.try_acquire()
359
+
360
+ if lock_info is None:
361
+ # Another instance holds the lock — check if healthy or stale
362
+ existing = lock_mgr.read_existing()
363
+ if existing is not None and existing.is_stale():
364
+ lock_mgr.force_cleanup()
365
+ lock_info = lock_mgr.try_acquire()
366
+
367
+ if lock_info is not None:
368
+ _serve_primary(repo_path, data_dir, lock_mgr)
369
+ else:
370
+ # Re-read in case the lock changed hands during stale cleanup.
371
+ existing = lock_mgr.read_existing()
372
+ if existing is None:
373
+ print("Error: cannot read lock info from primary", file=sys.stderr)
374
+ raise typer.Exit(code=1)
375
+ _serve_proxy(existing.socket)
376
+
377
+
378
+ def _serve_primary(repo_path: Path, data_dir: Path, lock_mgr) -> None:
379
+ """Run as primary: DB + watcher + MCP + socket server."""
380
+ import asyncio
381
+ import sys
382
+
383
+ from synaptiq.core.daemon.socket_server import SocketServer
384
+ from synaptiq.core.ingestion.pipeline import run_pipeline
385
+ from synaptiq.core.ingestion.watcher import watch_repo
386
+ from synaptiq.core.storage.kuzu_backend import KuzuBackend
387
+ from synaptiq.mcp.server import dispatch_resource, dispatch_tool, set_lock, set_storage
388
+ from synaptiq.mcp.server import server as mcp_server
389
+
390
+ db_path = data_dir / "kuzu"
391
+ storage = KuzuBackend()
392
+ storage.initialize(db_path)
393
+
394
+ if not (data_dir / "meta.json").exists():
395
+ print("Running initial index...", file=sys.stderr)
396
+ run_pipeline(repo_path, storage, full=True)
397
+
398
+ lock = asyncio.Lock()
399
+ set_storage(storage)
400
+ set_lock(lock)
401
+
402
+ def dispatch(method: str, params: dict) -> str:
403
+ if method == "ping":
404
+ return "pong"
405
+ if method == "tool":
406
+ return dispatch_tool(params.get("name", ""), params.get("arguments", {}), storage)
407
+ if method == "resource":
408
+ return dispatch_resource(params.get("uri", ""), storage)
409
+ return f"Unknown method: {method}"
410
+
411
+ socket_server = SocketServer(lock_mgr.socket_path, dispatch, lock=lock)
412
+
413
+ async def _run() -> None:
414
+ from mcp.server.stdio import stdio_server
415
+ stop = asyncio.Event()
416
+ await socket_server.start()
417
+ try:
418
+ async with stdio_server() as (read, write):
419
+ async def _mcp_then_stop():
420
+ await mcp_server.run(read, write, mcp_server.create_initialization_options())
421
+ stop.set()
422
+ await asyncio.gather(
423
+ _mcp_then_stop(),
424
+ watch_repo(repo_path, storage, stop_event=stop, lock=lock),
425
+ )
426
+ finally:
427
+ await socket_server.stop()
428
+
429
+ try:
430
+ asyncio.run(_run())
431
+ except KeyboardInterrupt:
432
+ pass
433
+ finally:
434
+ storage.close()
435
+ lock_mgr.release()
436
+
437
+
438
+ def _serve_proxy(socket_path: str) -> None:
439
+ """Run as proxy: MCP over stdio, forwarding to primary via socket."""
440
+ import asyncio
441
+
442
+ from synaptiq.core.daemon.socket_client import SocketClient
443
+ from synaptiq.mcp.server import set_proxy_client
444
+
445
+ client = SocketClient(Path(socket_path))
446
+
447
+ async def _run() -> None:
448
+ import sys
449
+
450
+ from mcp.server.stdio import stdio_server
451
+
452
+ from synaptiq.mcp.server import server as mcp_server
453
+
454
+ # Retry connection — the primary may still be starting its socket server.
455
+ for attempt in range(5):
456
+ try:
457
+ await client.connect()
458
+ break
459
+ except ConnectionError:
460
+ if attempt == 4:
461
+ print("Error: could not connect to primary socket", file=sys.stderr)
462
+ raise
463
+ await asyncio.sleep(0.2 * (attempt + 1))
464
+
465
+ set_proxy_client(client)
466
+ try:
467
+ async with stdio_server() as (read, write):
468
+ await mcp_server.run(read, write, mcp_server.create_initialization_options())
469
+ finally:
470
+ await client.close()
471
+
472
+ try:
473
+ asyncio.run(_run())
474
+ except KeyboardInterrupt:
475
+ pass
@@ -0,0 +1,13 @@
1
+ """Synaptiq configuration — ignore patterns and language detection."""
2
+
3
+ from synaptiq.config.ignore import DEFAULT_IGNORE_PATTERNS, load_gitignore, should_ignore
4
+ from synaptiq.config.languages import SUPPORTED_EXTENSIONS, get_language, is_supported
5
+
6
+ __all__ = [
7
+ "DEFAULT_IGNORE_PATTERNS",
8
+ "SUPPORTED_EXTENSIONS",
9
+ "get_language",
10
+ "is_supported",
11
+ "load_gitignore",
12
+ "should_ignore",
13
+ ]
@@ -0,0 +1,135 @@
1
+ """Ignore-pattern handling for Synaptiq's file discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from pathlib import Path
7
+
8
+ DEFAULT_IGNORE_PATTERNS: frozenset[str] = frozenset(
9
+ {
10
+ # Directories
11
+ "node_modules",
12
+ "__pycache__",
13
+ ".git",
14
+ ".synaptiq",
15
+ ".venv",
16
+ "venv",
17
+ ".env",
18
+ "dist",
19
+ "build",
20
+ ".idea",
21
+ ".vscode",
22
+ ".mypy_cache",
23
+ ".pytest_cache",
24
+ ".ruff_cache",
25
+ ".tox",
26
+ "egg-info",
27
+ ".eggs",
28
+ "coverage",
29
+ "htmlcov",
30
+ # Files (exact names)
31
+ ".DS_Store",
32
+ ".coverage",
33
+ "package-lock.json",
34
+ "yarn.lock",
35
+ "uv.lock",
36
+ "poetry.lock",
37
+ # File globs
38
+ "*.pyc",
39
+ "*.pyo",
40
+ "*.so",
41
+ "*.dylib",
42
+ "*.min.js",
43
+ "*.bundle.js",
44
+ "*.map",
45
+ }
46
+ )
47
+
48
+ # Separate glob patterns (contain wildcards) from literal names at module load
49
+ # so we only compute this once.
50
+ _GLOB_PATTERNS: frozenset[str] = frozenset(
51
+ p for p in DEFAULT_IGNORE_PATTERNS if "*" in p or "?" in p
52
+ )
53
+ _LITERAL_PATTERNS: frozenset[str] = DEFAULT_IGNORE_PATTERNS - _GLOB_PATTERNS
54
+
55
+ def _matches_default_patterns(path: Path) -> bool:
56
+ """Check whether *path* (relative) matches any default ignore pattern."""
57
+ for part in path.parts:
58
+ if part in _LITERAL_PATTERNS:
59
+ return True
60
+ # Also check globs against every component (e.g. *.pyc — unlikely but consistent)
61
+ for pattern in _GLOB_PATTERNS:
62
+ if fnmatch.fnmatch(part, pattern):
63
+ return True
64
+ return False
65
+
66
+ _pathspec_cache: dict[tuple[str, ...], object] = {}
67
+
68
+ def _matches_gitignore(path: Path, gitignore_patterns: list[str]) -> bool:
69
+ """Check *path* against a list of gitignore-style patterns.
70
+
71
+ Uses ``pathspec`` when available for full gitignore semantics; falls back to
72
+ fnmatch per-pattern otherwise. The compiled pathspec is cached by the
73
+ pattern content so it is only built once per unique pattern set.
74
+ """
75
+ if not gitignore_patterns:
76
+ return False
77
+
78
+ try:
79
+ import pathspec
80
+
81
+ cache_key = tuple(gitignore_patterns)
82
+ spec = _pathspec_cache.get(cache_key)
83
+ if spec is None:
84
+ spec = pathspec.PathSpec.from_lines("gitignore", gitignore_patterns)
85
+ _pathspec_cache[cache_key] = spec
86
+ return spec.match_file(str(path)) # type: ignore[union-attr]
87
+ except ImportError: # pragma: no cover — pathspec is a declared dependency
88
+ path_str = str(path)
89
+ for pattern in gitignore_patterns:
90
+ if fnmatch.fnmatch(path_str, pattern):
91
+ return True
92
+ if fnmatch.fnmatch(path.name, pattern):
93
+ return True
94
+ return False
95
+
96
+ def should_ignore(
97
+ path: str | Path,
98
+ gitignore_patterns: list[str] | None = None,
99
+ ) -> bool:
100
+ """Return ``True`` if *path* should be ignored during file discovery.
101
+
102
+ Parameters
103
+ ----------
104
+ path:
105
+ A relative file path (e.g. ``src/main.py`` or ``node_modules/pkg/index.js``).
106
+ gitignore_patterns:
107
+ Optional list of gitignore-style patterns loaded via :func:`load_gitignore`.
108
+ """
109
+ p = Path(path)
110
+
111
+ if _matches_default_patterns(p):
112
+ return True
113
+
114
+ if gitignore_patterns and _matches_gitignore(p, gitignore_patterns):
115
+ return True
116
+
117
+ return False
118
+
119
+ def load_gitignore(repo_path: Path) -> list[str]:
120
+ """Read ``.gitignore`` from *repo_path* and return a list of patterns.
121
+
122
+ Blank lines and comments (lines starting with ``#``) are stripped.
123
+ Returns an empty list when the file does not exist.
124
+ """
125
+ gitignore = repo_path / ".gitignore"
126
+ if not gitignore.is_file():
127
+ return []
128
+
129
+ lines: list[str] = []
130
+ text = gitignore.read_text(encoding="utf-8")
131
+ for raw_line in text.splitlines():
132
+ line = raw_line.strip()
133
+ if line and not line.startswith("#"):
134
+ lines.append(line)
135
+ return lines
@@ -0,0 +1,27 @@
1
+ """Language detection based on file extensions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ SUPPORTED_EXTENSIONS: dict[str, str] = {
8
+ ".py": "python",
9
+ ".ts": "typescript",
10
+ ".tsx": "typescript",
11
+ ".js": "javascript",
12
+ ".jsx": "javascript",
13
+ ".mjs": "javascript",
14
+ ".cjs": "javascript",
15
+ }
16
+
17
+ def get_language(file_path: str | Path) -> str | None:
18
+ """Return the language name for *file_path* based on its extension.
19
+
20
+ Returns ``None`` when the extension is not in :data:`SUPPORTED_EXTENSIONS`.
21
+ """
22
+ suffix = Path(file_path).suffix
23
+ return SUPPORTED_EXTENSIONS.get(suffix)
24
+
25
+ def is_supported(file_path: str | Path) -> bool:
26
+ """Return ``True`` if *file_path* has a supported extension."""
27
+ return Path(file_path).suffix in SUPPORTED_EXTENSIONS
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+ """Daemon coordination for multi-instance synaptiq serve --watch."""