siftcode 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.
- siftcode/__init__.py +3 -0
- siftcode/__main__.py +11 -0
- siftcode/_version.py +59 -0
- siftcode/cli.py +531 -0
- siftcode/core/__init__.py +1 -0
- siftcode/core/analyzer.py +575 -0
- siftcode/core/cache.py +300 -0
- siftcode/core/config.py +258 -0
- siftcode/core/llm.py +295 -0
- siftcode/core/manual.py +208 -0
- siftcode/core/prompts.py +215 -0
- siftcode/data/__init__.py +0 -0
- siftcode/data/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +9 -0
- siftcode/gui/analyzer_view.py +560 -0
- siftcode/gui/app.py +252 -0
- siftcode/gui/guide_window.py +88 -0
- siftcode/gui/project_list.py +160 -0
- siftcode/gui/settings_view.py +430 -0
- siftcode/gui/widgets/__init__.py +1 -0
- siftcode/gui/widgets/exclusion_list.py +169 -0
- siftcode/gui/widgets/log_view.py +108 -0
- siftcode/gui/widgets/markdown_view.py +205 -0
- siftcode/gui/widgets/project_row.py +141 -0
- siftcode/gui/window.py +446 -0
- siftcode-0.3.0.dist-info/METADATA +413 -0
- siftcode-0.3.0.dist-info/RECORD +31 -0
- siftcode-0.3.0.dist-info/WHEEL +5 -0
- siftcode-0.3.0.dist-info/entry_points.txt +5 -0
- siftcode-0.3.0.dist-info/licenses/LICENSE +674 -0
- siftcode-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core analyzer module for Siftcode.
|
|
3
|
+
|
|
4
|
+
Reads a project's files, sends them to a local LLM for analysis,
|
|
5
|
+
and produces a developer guide. Yields progress events so callers
|
|
6
|
+
(GTK UI, CLI) can update their display in real time.
|
|
7
|
+
|
|
8
|
+
No GTK dependencies — testable with a mock LLM client.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
from siftcode.core.cache import AnalysisCache, file_hash, string_hash
|
|
23
|
+
from siftcode.core.config import AnalysisConfig, ModelConfig, ProjectConfig
|
|
24
|
+
from siftcode.core.llm import LLMError, OllamaClient
|
|
25
|
+
from siftcode.core import prompts
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Progress event
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Progress:
|
|
36
|
+
"""A single progress event emitted by the analyzer."""
|
|
37
|
+
|
|
38
|
+
stage: str # "root", "file", "directory", "guide", "done"
|
|
39
|
+
current: str # what's being worked on (path or label)
|
|
40
|
+
completed: int # how many items done in this stage
|
|
41
|
+
total: int # how many items total in this stage
|
|
42
|
+
fraction: float # overall fraction 0.0 - 1.0
|
|
43
|
+
message: str = "" # optional human-readable status
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return f"[{self.stage}] {self.current} ({self.completed}/{self.total}) {self.fraction:.0%}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Findings — the accumulated result
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class Findings:
|
|
55
|
+
"""Everything the analyzer has collected about a project."""
|
|
56
|
+
|
|
57
|
+
root_summary: str = ""
|
|
58
|
+
directories: dict[str, str] = field(default_factory=dict)
|
|
59
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
errors: list[dict[str, str]] = field(default_factory=list)
|
|
61
|
+
started: str = ""
|
|
62
|
+
finished: str = ""
|
|
63
|
+
guide: str = ""
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
return {
|
|
67
|
+
"root_summary": self.root_summary,
|
|
68
|
+
"directories": self.directories,
|
|
69
|
+
"files": self.files,
|
|
70
|
+
"errors": self.errors,
|
|
71
|
+
"started": self.started,
|
|
72
|
+
"finished": self.finished,
|
|
73
|
+
"guide": self.guide,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_dict(cls, data: dict) -> "Findings":
|
|
78
|
+
return cls(
|
|
79
|
+
root_summary=data.get("root_summary", ""),
|
|
80
|
+
directories=data.get("directories", {}),
|
|
81
|
+
files=data.get("files", {}),
|
|
82
|
+
errors=data.get("errors", []),
|
|
83
|
+
started=data.get("started", ""),
|
|
84
|
+
finished=data.get("finished", ""),
|
|
85
|
+
guide=data.get("guide", ""),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def save(self, path: Path) -> None:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
91
|
+
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Analyzer
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
class ProjectAnalyzer:
|
|
99
|
+
"""
|
|
100
|
+
Analyzes a software project using a local LLM.
|
|
101
|
+
|
|
102
|
+
Usage:
|
|
103
|
+
config = ProjectConfig(name="My Project", path=Path("/path/to/proj"))
|
|
104
|
+
client = OllamaClient(config.model)
|
|
105
|
+
analyzer = ProjectAnalyzer(config, client)
|
|
106
|
+
|
|
107
|
+
for progress in analyzer.analyze_project():
|
|
108
|
+
print(progress)
|
|
109
|
+
|
|
110
|
+
findings = analyzer.findings
|
|
111
|
+
findings.save(Path("findings.json"))
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# Fraction of total work allocated to each phase
|
|
115
|
+
ROOT_WEIGHT = 0.05
|
|
116
|
+
FILES_WEIGHT = 0.70
|
|
117
|
+
DIRS_WEIGHT = 0.15
|
|
118
|
+
GUIDE_WEIGHT = 0.10
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
config: ProjectConfig,
|
|
123
|
+
client: OllamaClient,
|
|
124
|
+
cache: AnalysisCache | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.config = config
|
|
127
|
+
self.client = client
|
|
128
|
+
# Create a default cache unless caller provides one
|
|
129
|
+
self.cache = cache if cache is not None else AnalysisCache(
|
|
130
|
+
enabled=config.analysis.use_cache,
|
|
131
|
+
)
|
|
132
|
+
self.findings = Findings()
|
|
133
|
+
|
|
134
|
+
# Log-related paths (set on analyze_project())
|
|
135
|
+
self._log_dir: Path | None = None
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Public API
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def analyze_project(self) -> Iterator[Progress]:
|
|
142
|
+
"""
|
|
143
|
+
Run the full analysis pipeline, yielding Progress events.
|
|
144
|
+
|
|
145
|
+
Each yield happens AFTER the corresponding work is done, so
|
|
146
|
+
callers can trust that when they see an event, the work it
|
|
147
|
+
describes has completed. This is important for the GTK UI,
|
|
148
|
+
which shows 'Analysis complete' when the generator ends.
|
|
149
|
+
"""
|
|
150
|
+
self.findings = Findings(
|
|
151
|
+
started=datetime.now().isoformat(timespec="seconds")
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# 1. Root
|
|
155
|
+
yield Progress("root", str(self.config.path), 0, 1, 0.0,
|
|
156
|
+
"Analyzing project root...")
|
|
157
|
+
self._analyze_root()
|
|
158
|
+
yield Progress("root", str(self.config.path), 1, 1, self.ROOT_WEIGHT,
|
|
159
|
+
"Root analysis complete")
|
|
160
|
+
|
|
161
|
+
# 2. Files
|
|
162
|
+
files = self._collect_files()
|
|
163
|
+
if files:
|
|
164
|
+
for i, file_path in enumerate(files):
|
|
165
|
+
rel = self._rel(file_path)
|
|
166
|
+
# Work first, then yield progress
|
|
167
|
+
self._analyze_file(file_path)
|
|
168
|
+
yield Progress("file", rel, i + 1, len(files),
|
|
169
|
+
self.ROOT_WEIGHT + ((i + 1) / len(files)) * self.FILES_WEIGHT,
|
|
170
|
+
f"Analyzed {rel}")
|
|
171
|
+
total_after_files = self.ROOT_WEIGHT + self.FILES_WEIGHT
|
|
172
|
+
yield Progress("file", "", len(files), len(files),
|
|
173
|
+
total_after_files,
|
|
174
|
+
f"Analyzed {len(files)} files")
|
|
175
|
+
|
|
176
|
+
# 3. Directories
|
|
177
|
+
directories = self._collect_directories(files)
|
|
178
|
+
if directories:
|
|
179
|
+
for i, dir_path in enumerate(directories):
|
|
180
|
+
rel = self._rel(dir_path)
|
|
181
|
+
# Work first, then yield progress
|
|
182
|
+
self._analyze_directory(dir_path)
|
|
183
|
+
yield Progress("directory", rel, i + 1, len(directories),
|
|
184
|
+
total_after_files + ((i + 1) / len(directories)) * self.DIRS_WEIGHT,
|
|
185
|
+
f"Summarized {rel}")
|
|
186
|
+
total_after_dirs = total_after_files + self.DIRS_WEIGHT
|
|
187
|
+
yield Progress("directory", "", len(directories), len(directories),
|
|
188
|
+
total_after_dirs,
|
|
189
|
+
f"Summarized {len(directories)} directories")
|
|
190
|
+
|
|
191
|
+
# 4. Guide
|
|
192
|
+
yield Progress("guide", "", 0, 1, total_after_dirs,
|
|
193
|
+
"Generating developer guide...")
|
|
194
|
+
self._generate_guide()
|
|
195
|
+
self.findings.finished = datetime.now().isoformat(timespec="seconds")
|
|
196
|
+
|
|
197
|
+
yield Progress("done", "", 1, 1, 1.0, "Analysis complete")
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Stage 1: root
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _analyze_root(self) -> None:
|
|
204
|
+
"""Ask the LLM to summarize the project from its file tree."""
|
|
205
|
+
contents = self._root_tree_preview()
|
|
206
|
+
|
|
207
|
+
# Cache key: project path + file listing + manifest content.
|
|
208
|
+
# The path is essential so different projects don't collide.
|
|
209
|
+
# The manifest content catches changes to dependencies or
|
|
210
|
+
# build tooling that don't affect the file listing.
|
|
211
|
+
manifest = self._read_project_manifest()
|
|
212
|
+
key_input = f"{self.config.path.resolve()}\n{contents}\n{manifest}"
|
|
213
|
+
key = string_hash(key_input)
|
|
214
|
+
model = self.config.model.name
|
|
215
|
+
|
|
216
|
+
cached = self.cache.get_root(key, model=model)
|
|
217
|
+
if cached is not None:
|
|
218
|
+
logger.debug("Root cache hit")
|
|
219
|
+
self.findings.root_summary = cached
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
user_prompt = prompts.ROOT_SUMMARY.format(
|
|
223
|
+
project_dir=str(self.config.path),
|
|
224
|
+
contents=contents,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
response = self.client.chat(
|
|
229
|
+
system=prompts.SYSTEM_ROOT,
|
|
230
|
+
user=user_prompt,
|
|
231
|
+
)
|
|
232
|
+
summary = response.text.strip()
|
|
233
|
+
self.findings.root_summary = summary
|
|
234
|
+
self.cache.put_root(key, path=".", model=model, summary=summary)
|
|
235
|
+
except LLMError as e:
|
|
236
|
+
logger.error(f"Root analysis failed: {e}")
|
|
237
|
+
self.findings.errors.append({
|
|
238
|
+
"stage": "root",
|
|
239
|
+
"path": str(self.config.path),
|
|
240
|
+
"error": str(e),
|
|
241
|
+
})
|
|
242
|
+
self.findings.root_summary = "(root analysis failed)"
|
|
243
|
+
|
|
244
|
+
def _root_tree_preview(self) -> str:
|
|
245
|
+
"""Return a newline-separated list of project-relative paths."""
|
|
246
|
+
lines: list[str] = []
|
|
247
|
+
for root, dirs, files in os.walk(self.config.path):
|
|
248
|
+
# Filter out excluded directories in-place so os.walk skips them
|
|
249
|
+
dirs[:] = [
|
|
250
|
+
d for d in dirs
|
|
251
|
+
if not self._is_excluded(self._rel(Path(root) / d))
|
|
252
|
+
]
|
|
253
|
+
for name in files:
|
|
254
|
+
full = Path(root) / name
|
|
255
|
+
if not self._is_excluded(self._rel(full)):
|
|
256
|
+
lines.append(self._rel(full))
|
|
257
|
+
|
|
258
|
+
lines.sort()
|
|
259
|
+
return "\n".join(lines)
|
|
260
|
+
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
# Stage 2: files
|
|
263
|
+
# ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
''' def _collect_files(self) -> list[Path]:
|
|
266
|
+
"""Return a sorted list of all non-excluded files in the project."""
|
|
267
|
+
result: list[Path] = []
|
|
268
|
+
for root, dirs, files in os.walk(self.config.path):
|
|
269
|
+
dirs[:] = [
|
|
270
|
+
d for d in dirs
|
|
271
|
+
if not self._is_excluded(self._rel(Path(root) / d))
|
|
272
|
+
]
|
|
273
|
+
for name in files:
|
|
274
|
+
full = Path(root) / name
|
|
275
|
+
if not self._is_excluded(self._rel(full)):
|
|
276
|
+
result.append(full)
|
|
277
|
+
result.sort()
|
|
278
|
+
return result '''
|
|
279
|
+
|
|
280
|
+
def _collect_files(self) -> list[Path]:
|
|
281
|
+
"""Return a sorted list of non-excluded source and configuration files."""
|
|
282
|
+
root = Path(self.config.path).resolve()
|
|
283
|
+
if not root.is_dir():
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
result: list[Path] = []
|
|
287
|
+
for path in root.rglob("*"):
|
|
288
|
+
if path.is_file():
|
|
289
|
+
rel = self._rel(path)
|
|
290
|
+
if not self._is_excluded(rel):
|
|
291
|
+
# Otetaan mukaan .py-tiedostot TAI juuritason tärkeät tiedostot (kuten README.md, pyproject.toml)
|
|
292
|
+
if path.suffix.lower() == ".py" or path.parent == root:
|
|
293
|
+
result.append(path)
|
|
294
|
+
result.sort()
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
def _is_excluded(self, rel_path: str | Path) -> bool:
|
|
298
|
+
"""Check whether a project-relative path should be skipped."""
|
|
299
|
+
try:
|
|
300
|
+
parts = Path(rel_path).parts
|
|
301
|
+
name = Path(rel_path).name
|
|
302
|
+
except Exception:
|
|
303
|
+
parts = Path(str(rel_path)).parts
|
|
304
|
+
name = Path(str(rel_path)).name
|
|
305
|
+
|
|
306
|
+
exclude = self.config.analysis.exclude
|
|
307
|
+
|
|
308
|
+
# Tarkistetaan osat tai päätetyyppiset metadatakansiot
|
|
309
|
+
for part in parts:
|
|
310
|
+
if part in exclude:
|
|
311
|
+
return True
|
|
312
|
+
if part.endswith((".egg-info", ".dist-info", "__pycache__", ".mypy_cache", ".pytest_cache")):
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
# Editor backup files: end with ~ or .bak or .swp or .swo
|
|
316
|
+
for backup_suffix in ("~", ".bak", ".swp", ".swo"):
|
|
317
|
+
if name.endswith(backup_suffix):
|
|
318
|
+
return True
|
|
319
|
+
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _analyze_file(self, file_path: Path) -> None:
|
|
324
|
+
rel = self._rel(file_path)
|
|
325
|
+
logger.info(f"Analyzing file: {rel}")
|
|
326
|
+
|
|
327
|
+
content = self._read_text_file(file_path)
|
|
328
|
+
if content is None:
|
|
329
|
+
self.findings.errors.append({
|
|
330
|
+
"stage": "file",
|
|
331
|
+
"path": rel,
|
|
332
|
+
"error": "could not be read as UTF-8 text (likely binary)",
|
|
333
|
+
})
|
|
334
|
+
return
|
|
335
|
+
|
|
336
|
+
content = self._truncate(content, self.config.analysis.max_file_chars)
|
|
337
|
+
|
|
338
|
+
key = file_hash(file_path)
|
|
339
|
+
model = self.config.model.name
|
|
340
|
+
|
|
341
|
+
cached = self.cache.get_file(key, model=model)
|
|
342
|
+
if cached is not None:
|
|
343
|
+
logger.debug(f"File cache hit: {rel}")
|
|
344
|
+
self.findings.files[rel] = cached
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
context = self._file_context(rel)
|
|
348
|
+
user_prompt = prompts.FILE_ANALYSIS.format(
|
|
349
|
+
context=context,
|
|
350
|
+
path=rel,
|
|
351
|
+
content=content,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
response = self.client.chat(
|
|
356
|
+
system=prompts.SYSTEM_FILE,
|
|
357
|
+
user=user_prompt,
|
|
358
|
+
)
|
|
359
|
+
summary = response.text.strip()
|
|
360
|
+
self.findings.files[rel] = summary
|
|
361
|
+
self.cache.put_file(key, path=rel, model=model, summary=summary)
|
|
362
|
+
except LLMError as e:
|
|
363
|
+
logger.error(f"Error analyzing file {rel}: {e}")
|
|
364
|
+
self.findings.errors.append({
|
|
365
|
+
"stage": "file",
|
|
366
|
+
"path": rel,
|
|
367
|
+
"error": str(e),
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
def _read_project_manifest(self) -> str:
|
|
371
|
+
"""
|
|
372
|
+
Read the project's manifest file for global context.
|
|
373
|
+
|
|
374
|
+
Manifest files describe dependencies, build tooling, and
|
|
375
|
+
project metadata. They are valuable context for every file
|
|
376
|
+
analysis and help the model avoid inventing typical files
|
|
377
|
+
that don't exist.
|
|
378
|
+
"""
|
|
379
|
+
manifests = [
|
|
380
|
+
"pyproject.toml",
|
|
381
|
+
"package.json",
|
|
382
|
+
"Cargo.toml",
|
|
383
|
+
"go.mod",
|
|
384
|
+
"setup.py",
|
|
385
|
+
"setup.cfg",
|
|
386
|
+
"requirements.txt",
|
|
387
|
+
"Gemfile",
|
|
388
|
+
"pom.xml",
|
|
389
|
+
]
|
|
390
|
+
for name in manifests:
|
|
391
|
+
path = self.config.path / name
|
|
392
|
+
if not path.exists() or not path.is_file():
|
|
393
|
+
continue
|
|
394
|
+
if self._is_excluded(name):
|
|
395
|
+
continue
|
|
396
|
+
content = self._read_text_file(path)
|
|
397
|
+
if content:
|
|
398
|
+
return f"### Project manifest: {name}\n{content[:3000]}\n"
|
|
399
|
+
return ""
|
|
400
|
+
|
|
401
|
+
def _file_context(self, rel: str) -> str:
|
|
402
|
+
"""Return context string prepended to a file analysis prompt."""
|
|
403
|
+
parts: list[str] = []
|
|
404
|
+
|
|
405
|
+
# Project manifest — the strongest signal for tech stack.
|
|
406
|
+
manifest = self._read_project_manifest()
|
|
407
|
+
if manifest:
|
|
408
|
+
parts.append(manifest)
|
|
409
|
+
|
|
410
|
+
if self.findings.root_summary:
|
|
411
|
+
parts.append(
|
|
412
|
+
"Project overview:\n"
|
|
413
|
+
f"{self.findings.root_summary}\n"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
# Sibling files already analyzed in the same directory
|
|
417
|
+
parent = str(Path(rel).parent)
|
|
418
|
+
siblings = [
|
|
419
|
+
p for p in self.findings.files
|
|
420
|
+
if str(Path(p).parent) == parent and p != rel
|
|
421
|
+
]
|
|
422
|
+
if siblings:
|
|
423
|
+
listing = "\n".join(f"- {s}" for s in sorted(siblings))
|
|
424
|
+
parts.append(f"Other files in this directory:\n{listing}\n")
|
|
425
|
+
|
|
426
|
+
return "\n".join(parts)
|
|
427
|
+
|
|
428
|
+
# ------------------------------------------------------------------
|
|
429
|
+
# Stage 3: directories
|
|
430
|
+
# ------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def _collect_directories(self, files: list[Path]) -> list[Path]:
|
|
433
|
+
"""Return directories that contain at least one analyzed file."""
|
|
434
|
+
dirs: set[Path] = set()
|
|
435
|
+
for f in files:
|
|
436
|
+
rel = self._rel(f)
|
|
437
|
+
if rel in self.findings.files:
|
|
438
|
+
dirs.add(f.parent)
|
|
439
|
+
return sorted(dirs)
|
|
440
|
+
|
|
441
|
+
def _analyze_directory(self, dir_path: Path) -> None:
|
|
442
|
+
"""Summarize a directory based on the file summaries inside it."""
|
|
443
|
+
rel = self._rel(dir_path)
|
|
444
|
+
logger.info(f"Analyzing directory: {rel}")
|
|
445
|
+
|
|
446
|
+
# Gather summaries of files directly in this directory
|
|
447
|
+
parts: list[str] = []
|
|
448
|
+
for file_rel, summary in self.findings.files.items():
|
|
449
|
+
if str(Path(file_rel).parent) == rel:
|
|
450
|
+
parts.append(f"### {Path(file_rel).name}\n{summary}\n")
|
|
451
|
+
|
|
452
|
+
if not parts:
|
|
453
|
+
return # no summaries, skip
|
|
454
|
+
|
|
455
|
+
user_prompt = prompts.DIRECTORY_SUMMARY.format(
|
|
456
|
+
path=rel if rel else ".",
|
|
457
|
+
file_summaries="\n".join(parts),
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
response = self.client.chat(
|
|
462
|
+
system=prompts.SYSTEM_DIRECTORY,
|
|
463
|
+
user=user_prompt,
|
|
464
|
+
)
|
|
465
|
+
self.findings.directories[rel] = response.text.strip()
|
|
466
|
+
except LLMError as e:
|
|
467
|
+
logger.error(f"Error analyzing directory {rel}: {e}")
|
|
468
|
+
self.findings.errors.append({
|
|
469
|
+
"stage": "directory",
|
|
470
|
+
"path": rel,
|
|
471
|
+
"error": str(e),
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
# ------------------------------------------------------------------
|
|
475
|
+
# Stage 4: guide
|
|
476
|
+
# ------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
def _generate_guide(self) -> None:
|
|
479
|
+
"""Ask the LLM to compose the final developer guide."""
|
|
480
|
+
user_prompt = prompts.DEVELOPER_GUIDE.format(
|
|
481
|
+
context="",
|
|
482
|
+
root_summary=self.findings.root_summary or "(not available)",
|
|
483
|
+
directory_summaries=self._format_directory_summaries(),
|
|
484
|
+
file_summaries=self._format_file_summaries(),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
response = self.client.chat(
|
|
489
|
+
system=prompts.SYSTEM_GUIDE,
|
|
490
|
+
user=user_prompt,
|
|
491
|
+
max_tokens=self.config.model.max_tokens_guide,
|
|
492
|
+
)
|
|
493
|
+
self.findings.guide = response.text.strip()
|
|
494
|
+
except LLMError as e:
|
|
495
|
+
logger.error(f"Guide generation failed: {e}")
|
|
496
|
+
self.findings.errors.append({
|
|
497
|
+
"stage": "guide",
|
|
498
|
+
"path": "",
|
|
499
|
+
"error": str(e),
|
|
500
|
+
})
|
|
501
|
+
self.findings.guide = "(guide generation failed)"
|
|
502
|
+
|
|
503
|
+
def _format_directory_summaries(self) -> str:
|
|
504
|
+
if not self.findings.directories:
|
|
505
|
+
return "(no directories analyzed)"
|
|
506
|
+
parts = [
|
|
507
|
+
f"### {path or '.'}\n{summary}\n"
|
|
508
|
+
for path, summary in sorted(self.findings.directories.items())
|
|
509
|
+
]
|
|
510
|
+
return "\n".join(parts)
|
|
511
|
+
|
|
512
|
+
def _format_file_summaries(self) -> str:
|
|
513
|
+
if not self.findings.files:
|
|
514
|
+
return "(no files analyzed)"
|
|
515
|
+
parts = [
|
|
516
|
+
f"### {path}\n{summary}\n"
|
|
517
|
+
for path, summary in sorted(self.findings.files.items())
|
|
518
|
+
]
|
|
519
|
+
return "\n".join(parts)
|
|
520
|
+
|
|
521
|
+
# ------------------------------------------------------------------
|
|
522
|
+
# Helpers
|
|
523
|
+
# ------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def _rel(self, path: Path) -> str:
|
|
526
|
+
"""Return path relative to the project root as a POSIX string."""
|
|
527
|
+
try:
|
|
528
|
+
return path.relative_to(self.config.path).as_posix()
|
|
529
|
+
except ValueError:
|
|
530
|
+
# Path is outside project root — shouldn't happen but be safe
|
|
531
|
+
return str(path)
|
|
532
|
+
|
|
533
|
+
def _is_excluded(self, rel_path: str) -> bool:
|
|
534
|
+
"""Check whether a project-relative path should be skipped."""
|
|
535
|
+
parts = Path(rel_path).parts
|
|
536
|
+
name = Path(rel_path).name
|
|
537
|
+
suffix = Path(rel_path).suffix.lower()
|
|
538
|
+
exclude = self.config.analysis.exclude
|
|
539
|
+
|
|
540
|
+
# Directory/file name match (exact)
|
|
541
|
+
if any(part in exclude for part in parts):
|
|
542
|
+
return True
|
|
543
|
+
|
|
544
|
+
# Extension match
|
|
545
|
+
if suffix and suffix in exclude:
|
|
546
|
+
return True
|
|
547
|
+
|
|
548
|
+
# Editor backup files: end with ~ or .bak or .swp or .swo
|
|
549
|
+
for backup_suffix in ("~", ".bak", ".swp", ".swo"):
|
|
550
|
+
if name.endswith(backup_suffix):
|
|
551
|
+
return True
|
|
552
|
+
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
def _read_text_file(self, path: Path) -> str | None:
|
|
556
|
+
"""Read a file as UTF-8. Return None for binary or unreadable files."""
|
|
557
|
+
try:
|
|
558
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
559
|
+
return f.read()
|
|
560
|
+
except UnicodeDecodeError:
|
|
561
|
+
return None
|
|
562
|
+
except OSError as e:
|
|
563
|
+
logger.warning(f"Cannot read {path}: {e}")
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
@staticmethod
|
|
567
|
+
def _truncate(text: str, max_chars: int) -> str:
|
|
568
|
+
"""Truncate long text with a clear marker."""
|
|
569
|
+
if len(text) <= max_chars:
|
|
570
|
+
return text
|
|
571
|
+
head = text[:max_chars]
|
|
572
|
+
return (
|
|
573
|
+
f"{head}\n\n"
|
|
574
|
+
f"[... truncated, original length {len(text)} characters ...]"
|
|
575
|
+
)
|