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.
- synap_git/__init__.py +5 -0
- synap_git/api/__init__.py +5 -0
- synap_git/api/app.py +111 -0
- synap_git/api/static/index.html +615 -0
- synap_git/cli/__init__.py +3 -0
- synap_git/cli/__main__.py +4 -0
- synap_git/cli/main.py +1174 -0
- synap_git/config.py +169 -0
- synap_git/diagnostics/__init__.py +0 -0
- synap_git/diagnostics/logger.py +82 -0
- synap_git/diagnostics/tracing.py +71 -0
- synap_git/embeddings/__init__.py +0 -0
- synap_git/git/__init__.py +11 -0
- synap_git/git/state.py +139 -0
- synap_git/indexer/__init__.py +0 -0
- synap_git/indexer/daemon.py +143 -0
- synap_git/indexer/engine.py +327 -0
- synap_git/indexer/scanner.py +463 -0
- synap_git/indexer/wiki.py +78 -0
- synap_git/mcp/__init__.py +5 -0
- synap_git/mcp/server.py +232 -0
- synap_git/parser/__init__.py +0 -0
- synap_git/parser/registry.py +209 -0
- synap_git/provider/anthropic.py +86 -0
- synap_git/provider/base.py +44 -0
- synap_git/provider/factory.py +101 -0
- synap_git/provider/gemini.py +98 -0
- synap_git/provider/mock.py +66 -0
- synap_git/provider/ollama.py +98 -0
- synap_git/provider/openai.py +90 -0
- synap_git/py.typed +1 -0
- synap_git/retrieval/__init__.py +0 -0
- synap_git/retrieval/engine.py +258 -0
- synap_git/retrieval/memory.py +123 -0
- synap_git/storage/__init__.py +3 -0
- synap_git/storage/sqlite.py +575 -0
- synap_git/utils/__init__.py +0 -0
- synap_git/utils/serialization.py +68 -0
- synap_git-0.2.0.dist-info/METADATA +251 -0
- synap_git-0.2.0.dist-info/RECORD +43 -0
- synap_git-0.2.0.dist-info/WHEEL +4 -0
- synap_git-0.2.0.dist-info/entry_points.txt +2 -0
- 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")
|