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.
@@ -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
+ )