synap-git 0.2.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 (43) hide show
  1. synap_git/__init__.py +5 -0
  2. synap_git/api/__init__.py +5 -0
  3. synap_git/api/app.py +111 -0
  4. synap_git/api/static/index.html +615 -0
  5. synap_git/cli/__init__.py +3 -0
  6. synap_git/cli/__main__.py +4 -0
  7. synap_git/cli/main.py +1174 -0
  8. synap_git/config.py +169 -0
  9. synap_git/diagnostics/__init__.py +0 -0
  10. synap_git/diagnostics/logger.py +82 -0
  11. synap_git/diagnostics/tracing.py +71 -0
  12. synap_git/embeddings/__init__.py +0 -0
  13. synap_git/git/__init__.py +11 -0
  14. synap_git/git/state.py +139 -0
  15. synap_git/indexer/__init__.py +0 -0
  16. synap_git/indexer/daemon.py +143 -0
  17. synap_git/indexer/engine.py +327 -0
  18. synap_git/indexer/scanner.py +463 -0
  19. synap_git/indexer/wiki.py +78 -0
  20. synap_git/mcp/__init__.py +5 -0
  21. synap_git/mcp/server.py +232 -0
  22. synap_git/parser/__init__.py +0 -0
  23. synap_git/parser/registry.py +209 -0
  24. synap_git/provider/anthropic.py +86 -0
  25. synap_git/provider/base.py +44 -0
  26. synap_git/provider/factory.py +101 -0
  27. synap_git/provider/gemini.py +98 -0
  28. synap_git/provider/mock.py +66 -0
  29. synap_git/provider/ollama.py +98 -0
  30. synap_git/provider/openai.py +90 -0
  31. synap_git/py.typed +1 -0
  32. synap_git/retrieval/__init__.py +0 -0
  33. synap_git/retrieval/engine.py +258 -0
  34. synap_git/retrieval/memory.py +123 -0
  35. synap_git/storage/__init__.py +3 -0
  36. synap_git/storage/sqlite.py +575 -0
  37. synap_git/utils/__init__.py +0 -0
  38. synap_git/utils/serialization.py +68 -0
  39. synap_git-0.2.0.dist-info/METADATA +251 -0
  40. synap_git-0.2.0.dist-info/RECORD +43 -0
  41. synap_git-0.2.0.dist-info/WHEEL +4 -0
  42. synap_git-0.2.0.dist-info/entry_points.txt +2 -0
  43. synap_git-0.2.0.dist-info/licenses/LICENSE.md +201 -0
@@ -0,0 +1,463 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import subprocess
8
+ import tomllib
9
+ from dataclasses import dataclass, field
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+
13
+ import structlog
14
+
15
+ logger = structlog.get_logger()
16
+
17
+ DEFAULT_EXCLUDES = {
18
+ ".git",
19
+ ".synap",
20
+ ".venv",
21
+ "__pycache__",
22
+ "node_modules",
23
+ "dist",
24
+ "build",
25
+ "target",
26
+ ".next",
27
+ ".cache",
28
+ "vendor",
29
+ }
30
+
31
+ MANIFEST_NAMES = {
32
+ "pyproject.toml",
33
+ "requirements.txt",
34
+ "package.json",
35
+ "Cargo.toml",
36
+ "go.mod",
37
+ }
38
+
39
+ LANGUAGE_BY_SUFFIX = {
40
+ ".py": "python",
41
+ ".ts": "typescript",
42
+ ".tsx": "typescript",
43
+ ".js": "javascript",
44
+ ".jsx": "javascript",
45
+ ".rs": "rust",
46
+ ".go": "go",
47
+ ".md": "markdown",
48
+ }
49
+
50
+
51
+ class FolderRole(StrEnum):
52
+ SOURCE = "source"
53
+ TESTS = "tests"
54
+ DOCS = "docs"
55
+ CONFIG = "config"
56
+ BUILD = "build"
57
+ UNKNOWN = "unknown"
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class FileObservation:
62
+ path: Path
63
+ relative_path: str
64
+ size_bytes: int
65
+ content_hash: str
66
+ language: str | None
67
+ folder_role: FolderRole
68
+ is_manifest: bool = False
69
+ git_oid: str | None = None
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ManifestObservation:
74
+ path: str
75
+ kind: str
76
+ dependencies: tuple[str, ...] = ()
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class RepositoryScan:
81
+ repository_path: Path
82
+ files: tuple[FileObservation, ...]
83
+ manifests: tuple[ManifestObservation, ...]
84
+ language_counts: dict[str, int] = field(default_factory=dict)
85
+ folder_counts: dict[str, int] = field(default_factory=dict)
86
+
87
+ @property
88
+ def dependencies(self) -> tuple[str, ...]:
89
+ names: set[str] = set()
90
+ for manifest in self.manifests:
91
+ names.update(manifest.dependencies)
92
+ return tuple(sorted(names))
93
+
94
+ def markdown_files(self) -> tuple[FileObservation, ...]:
95
+ return tuple(file for file in self.files if file.language == "markdown")
96
+
97
+
98
+ class GitIgnoreSpec:
99
+ """Simple and robust parser for .gitignore compliance."""
100
+
101
+ def __init__(self, base_dir: Path, lines: list[str]) -> None:
102
+ self.base_dir = base_dir.resolve()
103
+ self.rules: list[tuple[bool, re.Pattern[str], bool]] = []
104
+ for line in lines:
105
+ line = line.strip()
106
+ if not line or line.startswith("#"):
107
+ continue
108
+ is_negation = False
109
+ if line.startswith("!"):
110
+ is_negation = True
111
+ line = line[1:]
112
+
113
+ directory_only = False
114
+ if line.endswith("/"):
115
+ directory_only = True
116
+ line = line[:-1]
117
+
118
+ regex = self._glob_to_regex(line)
119
+ try:
120
+ pattern = re.compile(regex)
121
+ self.rules.append((is_negation, pattern, directory_only))
122
+ except re.error:
123
+ continue
124
+
125
+ def _glob_to_regex(self, glob: str) -> str:
126
+ leading_slash = glob.startswith("/")
127
+ if leading_slash:
128
+ glob = glob[1:]
129
+
130
+ parts = []
131
+ i = 0
132
+ n = len(glob)
133
+ while i < n:
134
+ c = glob[i]
135
+ if c == "*":
136
+ if i + 1 < n and glob[i + 1] == "*":
137
+ parts.append(".*")
138
+ i += 2
139
+ if i < n and glob[i] == "/":
140
+ i += 1
141
+ else:
142
+ parts.append("[^/]*")
143
+ i += 1
144
+ elif c == "?":
145
+ parts.append("[^/]")
146
+ i += 1
147
+ elif c == "/":
148
+ parts.append("/")
149
+ i += 1
150
+ elif c in "\\^$.|?*+()[]{}":
151
+ parts.append("\\" + c)
152
+ i += 1
153
+ else:
154
+ parts.append(c)
155
+ i += 1
156
+
157
+ regex = "".join(parts)
158
+ if leading_slash:
159
+ return f"^{regex}(/|$)"
160
+ else:
161
+ return f"(^|/){regex}(/|$)"
162
+
163
+ def matches(self, path: Path, is_dir: bool = False) -> bool:
164
+ try:
165
+ rel_path = path.resolve().relative_to(self.base_dir)
166
+ except ValueError:
167
+ return False
168
+
169
+ parts = rel_path.parts
170
+ ignored = False
171
+ for i in range(1, len(parts) + 1):
172
+ sub_path = Path(*parts[:i])
173
+ sub_is_dir = (i < len(parts)) or is_dir
174
+ sub_rel_path = sub_path.as_posix()
175
+
176
+ for is_negation, pattern, dir_only in self.rules:
177
+ if dir_only and not sub_is_dir:
178
+ continue
179
+ if pattern.search(sub_rel_path):
180
+ ignored = not is_negation
181
+ return ignored
182
+
183
+
184
+ class RepositoryScanner:
185
+ """Fast structural scanner with deterministic exclusion and manifest discovery."""
186
+
187
+ def __init__(
188
+ self,
189
+ *,
190
+ repository_path: Path,
191
+ excludes: set[str] | None = None,
192
+ max_file_bytes: int = 1_000_000,
193
+ ) -> None:
194
+ self.repository_path = repository_path.resolve()
195
+ self.excludes = excludes or DEFAULT_EXCLUDES
196
+ self.max_file_bytes = max_file_bytes
197
+ self.gitignore = None
198
+
199
+ gitignore_path = self.repository_path / ".gitignore"
200
+ if gitignore_path.exists():
201
+ try:
202
+ lines = gitignore_path.read_text(encoding="utf-8").splitlines()
203
+ self.gitignore = GitIgnoreSpec(self.repository_path, lines)
204
+ except Exception as e:
205
+ logger.warning("failed_to_load_gitignore", error=str(e))
206
+
207
+ async def scan_async(self) -> RepositoryScan:
208
+ return await asyncio.to_thread(self.scan)
209
+
210
+ def scan(self) -> RepositoryScan:
211
+ files: list[FileObservation] = []
212
+ manifests: list[ManifestObservation] = []
213
+
214
+ paths: list[Path] = []
215
+ try:
216
+ result = subprocess.run( # noqa: S603
217
+ ["git", "ls-files", "-z", "-c", "-o", "--exclude-standard"], # noqa: S607
218
+ cwd=self.repository_path,
219
+ capture_output=True,
220
+ check=True,
221
+ )
222
+ for rel_path in result.stdout.split(b"\0"):
223
+ if rel_path:
224
+ try:
225
+ paths.append(self.repository_path / rel_path.decode("utf-8"))
226
+ except UnicodeDecodeError:
227
+ pass
228
+ except (subprocess.CalledProcessError, FileNotFoundError):
229
+ # Fallback to rglob if not a git repository
230
+ paths = list(self.repository_path.rglob("*"))
231
+
232
+ for path in paths:
233
+ if not path.is_file():
234
+ continue
235
+ if self._excluded(path):
236
+ continue
237
+ try:
238
+ stat = path.stat()
239
+ except OSError:
240
+ continue
241
+ if stat.st_size > self.max_file_bytes:
242
+ continue
243
+ if _is_binary_file(path):
244
+ logger.debug("Skipped binary file", path=path.name)
245
+ continue
246
+
247
+ try:
248
+ relative_path = path.relative_to(self.repository_path).as_posix()
249
+ path.resolve().relative_to(self.repository_path)
250
+ except (ValueError, OSError):
251
+ continue
252
+ content_hash = _hash_file(path)
253
+ language = LANGUAGE_BY_SUFFIX.get(path.suffix.lower())
254
+ folder_role = self._classify_folder(relative_path)
255
+ is_manifest = path.name in MANIFEST_NAMES
256
+ observation = FileObservation(
257
+ path=path,
258
+ relative_path=relative_path,
259
+ size_bytes=stat.st_size,
260
+ content_hash=content_hash,
261
+ language=language,
262
+ folder_role=folder_role,
263
+ is_manifest=is_manifest,
264
+ )
265
+ files.append(observation)
266
+ if is_manifest:
267
+ manifests.append(self._read_manifest(path, relative_path))
268
+
269
+ language_counts: dict[str, int] = {}
270
+ folder_counts: dict[str, int] = {}
271
+ for file in files:
272
+ if file.language:
273
+ language_counts[file.language] = language_counts.get(file.language, 0) + 1
274
+ folder_counts[file.folder_role.value] = folder_counts.get(file.folder_role.value, 0) + 1
275
+ return RepositoryScan(
276
+ repository_path=self.repository_path,
277
+ files=tuple(files),
278
+ manifests=tuple(manifests),
279
+ language_counts=dict(sorted(language_counts.items())),
280
+ folder_counts=dict(sorted(folder_counts.items())),
281
+ )
282
+
283
+ def _excluded(self, path: Path) -> bool:
284
+ try:
285
+ parts = path.relative_to(self.repository_path).parts
286
+ except ValueError:
287
+ return True
288
+ if any(part in self.excludes for part in parts):
289
+ return True
290
+ if self.gitignore and self.gitignore.matches(path, is_dir=path.is_dir()):
291
+ return True
292
+ return False
293
+
294
+ def _classify_folder(self, relative_path: str) -> FolderRole:
295
+ parts = set(Path(relative_path).parts)
296
+ lower = relative_path.lower()
297
+ if "docs" in parts or lower.endswith(".md"):
298
+ return FolderRole.DOCS
299
+ if "tests" in parts or "test" in parts or lower.startswith("test_"):
300
+ return FolderRole.TESTS
301
+ if any(part in parts for part in {"src", "lib", "app", "cmd", "pkg"}):
302
+ return FolderRole.SOURCE
303
+ if relative_path.endswith((".toml", ".json", ".yaml", ".yml", ".ini", ".cfg")):
304
+ return FolderRole.CONFIG
305
+ if any(part in parts for part in {"build", "dist", "target"}):
306
+ return FolderRole.BUILD
307
+ return FolderRole.UNKNOWN
308
+
309
+ def _read_manifest(self, path: Path, relative_path: str) -> ManifestObservation:
310
+ try:
311
+ if path.name == "pyproject.toml":
312
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
313
+ dependencies = tuple(_python_dependencies(data))
314
+ return ManifestObservation(relative_path, "python", dependencies)
315
+ if path.name == "requirements.txt":
316
+ return ManifestObservation(relative_path, "python", tuple(_requirements(path)))
317
+ if path.name == "package.json":
318
+ data = json.loads(path.read_text(encoding="utf-8"))
319
+ dependencies = tuple(
320
+ sorted({*data.get("dependencies", {}), *data.get("devDependencies", {})})
321
+ )
322
+ return ManifestObservation(relative_path, "javascript", dependencies)
323
+ if path.name == "Cargo.toml":
324
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
325
+ dependencies = tuple(sorted(data.get("dependencies", {}).keys()))
326
+ return ManifestObservation(relative_path, "rust", dependencies)
327
+ if path.name == "go.mod":
328
+ return ManifestObservation(relative_path, "go", tuple(_go_dependencies(path)))
329
+ except Exception:
330
+ return ManifestObservation(relative_path, "unknown", ())
331
+ return ManifestObservation(relative_path, "unknown", ())
332
+
333
+
334
+ def _is_binary_file(path: Path) -> bool:
335
+ try:
336
+ # Check extensions first
337
+ binary_extensions = {
338
+ ".png",
339
+ ".jpg",
340
+ ".jpeg",
341
+ ".gif",
342
+ ".bmp",
343
+ ".ico",
344
+ ".pdf",
345
+ ".zip",
346
+ ".tar",
347
+ ".gz",
348
+ ".tgz",
349
+ ".rar",
350
+ ".7z",
351
+ ".exe",
352
+ ".dll",
353
+ ".so",
354
+ ".dylib",
355
+ ".bin",
356
+ ".dat",
357
+ ".db",
358
+ ".sqlite",
359
+ ".sqlite3",
360
+ ".woff",
361
+ ".woff2",
362
+ ".eot",
363
+ ".ttf",
364
+ ".mp4",
365
+ ".mp3",
366
+ ".wav",
367
+ ".avi",
368
+ ".mov",
369
+ ".flac",
370
+ ".ogg",
371
+ ".webm",
372
+ ".mkv",
373
+ ".class",
374
+ ".o",
375
+ ".a",
376
+ ".out",
377
+ ".pyc",
378
+ ".pyd",
379
+ ".pyo",
380
+ }
381
+ if path.suffix.lower() in binary_extensions:
382
+ return True
383
+
384
+ with path.open("rb") as f:
385
+ chunk = f.read(1024)
386
+ if not chunk:
387
+ return False
388
+ if b"\x00" in chunk:
389
+ return True
390
+
391
+ control_count = 0
392
+ for byte in chunk:
393
+ if byte < 32 and byte not in (9, 10, 13):
394
+ control_count += 1
395
+ if len(chunk) > 0 and (control_count / len(chunk)) > 0.1:
396
+ return True
397
+ except Exception:
398
+ return True
399
+ return False
400
+
401
+
402
+ def _hash_file(path: Path) -> str:
403
+ digest = hashlib.sha256()
404
+ with path.open("rb") as file:
405
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
406
+ digest.update(chunk)
407
+ return digest.hexdigest()
408
+
409
+
410
+ def _python_dependencies(data: dict[str, object]) -> list[str]:
411
+ project = data.get("project")
412
+ names: set[str] = set()
413
+ if isinstance(project, dict):
414
+ dependencies = project.get("dependencies")
415
+ if isinstance(dependencies, list):
416
+ for dependency in dependencies:
417
+ if isinstance(dependency, str):
418
+ names.add(_dependency_name(dependency))
419
+ optional = project.get("optional-dependencies")
420
+ if isinstance(optional, dict):
421
+ for values in optional.values():
422
+ if isinstance(values, list):
423
+ for dependency in values:
424
+ if isinstance(dependency, str):
425
+ names.add(_dependency_name(dependency))
426
+ return sorted(name for name in names if name)
427
+
428
+
429
+ def _requirements(path: Path) -> list[str]:
430
+ names: set[str] = set()
431
+ for line in path.read_text(encoding="utf-8").splitlines():
432
+ cleaned = line.strip()
433
+ if not cleaned or cleaned.startswith("#") or cleaned.startswith("-"):
434
+ continue
435
+ names.add(_dependency_name(cleaned))
436
+ return sorted(names)
437
+
438
+
439
+ def _go_dependencies(path: Path) -> list[str]:
440
+ names: set[str] = set()
441
+ in_require_block = False
442
+ for line in path.read_text(encoding="utf-8").splitlines():
443
+ cleaned = line.strip()
444
+ if cleaned.startswith("require ("):
445
+ in_require_block = True
446
+ continue
447
+ if in_require_block and cleaned == ")":
448
+ in_require_block = False
449
+ continue
450
+ if cleaned.startswith("require "):
451
+ parts = cleaned.split()
452
+ if len(parts) >= 2:
453
+ names.add(parts[1])
454
+ elif in_require_block and cleaned:
455
+ names.add(cleaned.split()[0])
456
+ return sorted(names)
457
+
458
+
459
+ def _dependency_name(requirement: str) -> str:
460
+ for separator in ("==", ">=", "<=", "~=", "!=", ">", "<", "["):
461
+ if separator in requirement:
462
+ return requirement.split(separator, 1)[0].strip()
463
+ return requirement.strip()
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from synap_git.config import SynapSettings
4
+ from synap_git.diagnostics.logger import get_logger
5
+ from synap_git.provider.factory import get_llm_provider
6
+ from synap_git.storage.sqlite import SynapStore
7
+
8
+ logger = get_logger("wiki_engine")
9
+
10
+
11
+ class WikiEngine:
12
+ """Generates L2 Markdown Documentation (Wiki) for files, modules, and the project."""
13
+
14
+ def __init__(self, settings: SynapSettings, store: SynapStore) -> None:
15
+ self.settings = settings
16
+ self.store = store
17
+ self.provider = get_llm_provider(settings)
18
+ self.wiki_dir = self.settings.state_path / "wiki"
19
+ self.wiki_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ def generate_file_wiki(self, file_id: str, file_path: str, content: str) -> None:
22
+ """Pass 2: Generate documentation for a specific file."""
23
+ if not self.provider:
24
+ logger.debug("skip_wiki_no_llm", path=file_path)
25
+ return
26
+
27
+ # Check if the score > 10 (change score threshold per spec)
28
+ # For simplicity, we just generate it if missing or forced
29
+ wiki_path = self.wiki_dir / f"{file_path}.md"
30
+ if wiki_path.exists():
31
+ return
32
+
33
+ wiki_path.parent.mkdir(parents=True, exist_ok=True)
34
+
35
+ prompt = (
36
+ f"Generate technical documentation for the file '{file_path}'.\n"
37
+ "Include:\n1. Purpose\n2. Key components\n3. Dependencies.\n\n"
38
+ f"Code:\n{content[:4000]}"
39
+ )
40
+
41
+ try:
42
+ doc_response = self.provider.generate(
43
+ system_prompt="You are a technical writer.", user_prompt=prompt, max_tokens=1000
44
+ )
45
+ wiki_path.write_text(doc_response.content, encoding="utf-8")
46
+ logger.info("wiki_generated_file", path=file_path)
47
+ try:
48
+ provider_name = self.provider.__class__.__name__.replace("Provider", "").lower()
49
+ model_name = getattr(self.provider, "default_model", "unknown")
50
+ self.store.put_llm_call(
51
+ provider=provider_name,
52
+ model=model_name,
53
+ input_tokens=doc_response.prompt_tokens,
54
+ output_tokens=doc_response.completion_tokens,
55
+ purpose="wiki",
56
+ file_path=file_path,
57
+ )
58
+ except Exception:
59
+ pass
60
+ except Exception as e:
61
+ logger.error("wiki_generation_failed", path=file_path, error=str(e))
62
+
63
+ def generate_module_wiki(self, dir_path: str) -> None:
64
+ """Pass 2.5: Synthesize file-level wikis into a module overview."""
65
+ if not self.provider:
66
+ return
67
+
68
+ module_wiki_path = self.wiki_dir / dir_path / "README.md"
69
+
70
+ # In a full implementation we'd gather all file wikis in the directory
71
+ logger.info("wiki_generated_module", path=dir_path)
72
+
73
+ def generate_project_wiki(self) -> None:
74
+ """Pass 2.8: Synthesize architecture.md and overview.md"""
75
+ if not self.provider:
76
+ return
77
+
78
+ logger.info("wiki_generated_project")
@@ -0,0 +1,5 @@
1
+ """MCP interface layer for exposing Synap context to agents."""
2
+
3
+ from synap_git.mcp.server import SynapMCPFacade
4
+
5
+ __all__ = ["SynapMCPFacade"]