siftcode 0.3.0__tar.gz → 0.3.1__tar.gz

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 (56) hide show
  1. {siftcode-0.3.0/src/siftcode.egg-info → siftcode-0.3.1}/PKG-INFO +41 -15
  2. {siftcode-0.3.0 → siftcode-0.3.1}/README.md +37 -13
  3. {siftcode-0.3.0 → siftcode-0.3.1}/pyproject.toml +12 -3
  4. siftcode-0.3.1/src/siftcode/__init__.py +5 -0
  5. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/_version.py +48 -4
  6. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/cli.py +149 -28
  7. siftcode-0.3.1/src/siftcode/core/__init__.py +1 -0
  8. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/core/analyzer.py +156 -88
  9. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/core/cache.py +80 -21
  10. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/core/config.py +91 -17
  11. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/core/llm.py +62 -8
  12. siftcode-0.3.1/src/siftcode/core/manual.py +385 -0
  13. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/core/prompts.py +33 -0
  14. siftcode-0.3.1/src/siftcode/data/hicolor/128x128/apps/siftcode-logo.png +0 -0
  15. siftcode-0.3.1/src/siftcode/data/hicolor/256x256/apps/siftcode-logo.png +0 -0
  16. siftcode-0.3.1/src/siftcode/data/hicolor/48x48/apps/siftcode-logo.png +0 -0
  17. siftcode-0.3.1/src/siftcode/gui/__init__.py +11 -0
  18. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/app.py +54 -59
  19. {siftcode-0.3.0 → siftcode-0.3.1/src/siftcode.egg-info}/PKG-INFO +41 -15
  20. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode.egg-info/SOURCES.txt +6 -0
  21. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode.egg-info/requires.txt +3 -1
  22. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_analyzer.py +12 -3
  23. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_cache.py +18 -2
  24. siftcode-0.3.1/tests/test_cli_extended.py +629 -0
  25. siftcode-0.3.1/tests/test_gui_extended.py +249 -0
  26. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_llm.py +15 -2
  27. siftcode-0.3.1/tests/test_manual.py +478 -0
  28. siftcode-0.3.0/src/siftcode/__init__.py +0 -3
  29. siftcode-0.3.0/src/siftcode/core/__init__.py +0 -1
  30. siftcode-0.3.0/src/siftcode/core/manual.py +0 -208
  31. siftcode-0.3.0/src/siftcode/gui/__init__.py +0 -9
  32. {siftcode-0.3.0 → siftcode-0.3.1}/LICENSE +0 -0
  33. {siftcode-0.3.0 → siftcode-0.3.1}/setup.cfg +0 -0
  34. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/__main__.py +0 -0
  35. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/data/__init__.py +0 -0
  36. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/data/siftcode-logo.png +0 -0
  37. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/analyzer_view.py +0 -0
  38. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/guide_window.py +0 -0
  39. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/project_list.py +0 -0
  40. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/settings_view.py +0 -0
  41. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/widgets/__init__.py +0 -0
  42. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/widgets/exclusion_list.py +0 -0
  43. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/widgets/log_view.py +0 -0
  44. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/widgets/markdown_view.py +0 -0
  45. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/widgets/project_row.py +0 -0
  46. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode/gui/window.py +0 -0
  47. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode.egg-info/dependency_links.txt +0 -0
  48. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode.egg-info/entry_points.txt +0 -0
  49. {siftcode-0.3.0 → siftcode-0.3.1}/src/siftcode.egg-info/top_level.txt +0 -0
  50. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_cli.py +0 -0
  51. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_config.py +0 -0
  52. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_gui_analyzer.py +0 -0
  53. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_gui_project_list.py +0 -0
  54. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_gui_settings.py +0 -0
  55. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_gui_smoke.py +0 -0
  56. {siftcode-0.3.0 → siftcode-0.3.1}/tests/test_prompts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: siftcode
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Local LLM-powered code analysis and developer guide generator
5
5
  Author-email: Tuomas Lähteenmäki <lahtis@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -14,14 +14,16 @@ Classifier: Environment :: X11 Applications :: GTK
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
17
18
  Classifier: Topic :: Software Development :: Documentation
18
19
  Requires-Python: >=3.11
19
20
  Description-Content-Type: text/markdown
20
21
  License-File: LICENSE
21
22
  Requires-Dist: requests>=2.31
22
- Requires-Dist: PyGObject>=3.46
23
23
  Requires-Dist: platformdirs>=4.0
24
24
  Requires-Dist: tomli-w>=1.0
25
+ Provides-Extra: gui
26
+ Requires-Dist: PyGObject>=3.46; extra == "gui"
25
27
  Provides-Extra: dev
26
28
  Requires-Dist: pytest>=7.4; extra == "dev"
27
29
  Requires-Dist: pytest-cov>=4.1; extra == "dev"
@@ -71,11 +73,10 @@ generated in minutes.
71
73
  - **Project-wide context**: file analyses see the root summary and
72
74
  sibling files, producing coherent guides
73
75
  - **Progress feedback**: a live progress bar and log during analysis
74
- - **Cancellable**: stop an analysis mid-run without losing your work
75
76
  - **Configurable per project**: model, context size, temperature, file
76
77
  exclusions, and cache settings are all saved per project
77
78
  - **Two front-ends**: a modern GTK4 + libadwaita GUI and a scriptable CLI
78
- - **Tested**: 200+ unit tests, ~95 % coverage in the core
79
+ - **Tested**: 305 unit tests, 78 % overall coverage (88–100 % for core modules)
79
80
 
80
81
  ---
81
82
 
@@ -102,8 +103,8 @@ generated in minutes.
102
103
 
103
104
  - **Linux** (X11 or Wayland)
104
105
  - **Python 3.11** or newer
105
- - **GTK 4.10+** and **libadwaita 1.4+**
106
- - **[Ollama](https://ollama.com)** running locally
106
+ - **GTK 4.10+** and **libadwaita 1.5+** (for the GUI)
107
+ - **[Ollama](https://ollama.com)** 0.1.24 or newer, running locally
107
108
  - A code-capable LLM installed via Ollama, for example:
108
109
 
109
110
  ```bash
@@ -151,8 +152,7 @@ build. CPU-only operation works but is slow (expect 5–15× longer runs).
151
152
 
152
153
  ### Recommended context length
153
154
 
154
- The default `num_ctx` has been increased from 8192 to 32768
155
- tokens.
155
+ The default `num_ctx` is 32768 tokens.
156
156
 
157
157
  **Why:** The guide generation prompt includes the root summary,
158
158
  all directory summaries, and all file summaries. For a 40-file
@@ -172,8 +172,12 @@ usage is ~13 GB. Comfortably within 24 GB.
172
172
  ---
173
173
 
174
174
  ## Installation
175
+ ```bash
176
+ pip install siftcode
177
+ ```
178
+ > Note: the GTK GUI requires system packages listed under System dependencies. The CLI works without them
175
179
 
176
- ### From source (recommended for now)
180
+ ### Development installation
177
181
 
178
182
  ```bash
179
183
  git clone https://codeberg.org/lahtis/siftcode.git
@@ -187,7 +191,7 @@ pip install -e ".[dev]"
187
191
 
188
192
  ```bash
189
193
  siftcode --version
190
- # → siftcode 0.3.0
194
+
191
195
 
192
196
  siftcode about
193
197
  # → prints version, author, and license
@@ -270,9 +274,9 @@ siftcode cache gc --days 30 # prune entries unused for 30 days
270
274
 
271
275
  Analysis produces two files in the output directory:
272
276
 
273
- - `findings.json` — raw structured findings (root, directories, files, errors)
274
- - `guide.md` — the generated developer guide
275
- - `docs/specs/` — advanced per-file technical specifications (when `--advanced` is used)
277
+ - `findings_<project>_<timestamp>.json` — raw structured findings (root, directories, files, errors)
278
+ - `guide_<project>_<timestamp>.md` — the generated developer guide
279
+ - `specs_<project>_<timestamp>/` — advanced per-file technical specifications (when `--advanced` is used)
276
280
 
277
281
  ---
278
282
 
@@ -315,13 +319,32 @@ human-readable and can be edited by hand if you prefer.
315
319
  4. **Guide generation** — all of the above is composed into a
316
320
  structured Markdown developer guide.
317
321
  5. **Advanced specs (optional)** — when `--advanced` is used, individual
318
- per-file technical specifications are generated and stored in `docs/specs/`.
322
+ per-file technical specifications are generated and stored in `specs_<project>_<timestamp>/`.
319
323
 
320
324
  Each step is cached by SHA-256 hash of the input. When you re-run on an
321
325
  unchanged project, only the guide is regenerated.
322
326
 
323
327
  ---
324
328
 
329
+ ### Beyond documentation: spec-driven development
330
+
331
+ Because `--advanced` produces per-file technical specifications in Markdown
332
+ (`specs_<project>_<timestamp>/`), the output can be used as an implementation spec — not just
333
+ as documentation.
334
+
335
+ Feed `guide_<project>_<timestamp>.md` and the `specs_<project>_<timestamp>/` directory to an AI coding assistant and ask it to build the project from scratch. The result is a new codebase that follows the original architecture and interfaces. This makes Siftcode useful not only for understanding existing code, but also for:
336
+
337
+ - **Porting** a project to another language — the specs are language-agnostic
338
+ - **Rebuilding** a project on a clean foundation — same design, new implementation
339
+ - **Starting from a spec** — write or generate the specs first, then let the AI implement them
340
+
341
+ > **Note:** Specs are generated by an LLM and may contain inaccuracies
342
+ > (the 14B model occasionally invents files that don't exist). Review them
343
+ > before using them as an implementation spec. Larger models produce more
344
+ > accurate specs.
345
+
346
+ ---
347
+
325
348
  ## Development
326
349
 
327
350
  ```bash
@@ -341,6 +364,9 @@ ruff check src/ tests/
341
364
  # Type-check
342
365
  mypy src/
343
366
  ```
367
+ > Note: GUI tests require a display. Use xvfb-run in headless
368
+ environments; they are skipped automatically when no display is
369
+ available.
344
370
 
345
371
  ### Project layout
346
372
 
@@ -355,7 +381,7 @@ siftcode/
355
381
  │ │ ├── config.py # project configuration
356
382
  │ │ ├── llm.py # Ollama client
357
383
  │ │ ├── prompts.py # prompt templates
358
- │ │ └── manual.py # Advanced Technical Analysis & Specification Generator
384
+ │ │ └── manual.py # advanced specification generator
359
385
  │ └── gui/ # GTK4 front-end
360
386
  │ ├── app.py
361
387
  │ ├── window.py
@@ -40,11 +40,10 @@ generated in minutes.
40
40
  - **Project-wide context**: file analyses see the root summary and
41
41
  sibling files, producing coherent guides
42
42
  - **Progress feedback**: a live progress bar and log during analysis
43
- - **Cancellable**: stop an analysis mid-run without losing your work
44
43
  - **Configurable per project**: model, context size, temperature, file
45
44
  exclusions, and cache settings are all saved per project
46
45
  - **Two front-ends**: a modern GTK4 + libadwaita GUI and a scriptable CLI
47
- - **Tested**: 200+ unit tests, ~95 % coverage in the core
46
+ - **Tested**: 305 unit tests, 78 % overall coverage (88–100 % for core modules)
48
47
 
49
48
  ---
50
49
 
@@ -71,8 +70,8 @@ generated in minutes.
71
70
 
72
71
  - **Linux** (X11 or Wayland)
73
72
  - **Python 3.11** or newer
74
- - **GTK 4.10+** and **libadwaita 1.4+**
75
- - **[Ollama](https://ollama.com)** running locally
73
+ - **GTK 4.10+** and **libadwaita 1.5+** (for the GUI)
74
+ - **[Ollama](https://ollama.com)** 0.1.24 or newer, running locally
76
75
  - A code-capable LLM installed via Ollama, for example:
77
76
 
78
77
  ```bash
@@ -120,8 +119,7 @@ build. CPU-only operation works but is slow (expect 5–15× longer runs).
120
119
 
121
120
  ### Recommended context length
122
121
 
123
- The default `num_ctx` has been increased from 8192 to 32768
124
- tokens.
122
+ The default `num_ctx` is 32768 tokens.
125
123
 
126
124
  **Why:** The guide generation prompt includes the root summary,
127
125
  all directory summaries, and all file summaries. For a 40-file
@@ -141,8 +139,12 @@ usage is ~13 GB. Comfortably within 24 GB.
141
139
  ---
142
140
 
143
141
  ## Installation
142
+ ```bash
143
+ pip install siftcode
144
+ ```
145
+ > Note: the GTK GUI requires system packages listed under System dependencies. The CLI works without them
144
146
 
145
- ### From source (recommended for now)
147
+ ### Development installation
146
148
 
147
149
  ```bash
148
150
  git clone https://codeberg.org/lahtis/siftcode.git
@@ -156,7 +158,7 @@ pip install -e ".[dev]"
156
158
 
157
159
  ```bash
158
160
  siftcode --version
159
- # → siftcode 0.3.0
161
+
160
162
 
161
163
  siftcode about
162
164
  # → prints version, author, and license
@@ -239,9 +241,9 @@ siftcode cache gc --days 30 # prune entries unused for 30 days
239
241
 
240
242
  Analysis produces two files in the output directory:
241
243
 
242
- - `findings.json` — raw structured findings (root, directories, files, errors)
243
- - `guide.md` — the generated developer guide
244
- - `docs/specs/` — advanced per-file technical specifications (when `--advanced` is used)
244
+ - `findings_<project>_<timestamp>.json` — raw structured findings (root, directories, files, errors)
245
+ - `guide_<project>_<timestamp>.md` — the generated developer guide
246
+ - `specs_<project>_<timestamp>/` — advanced per-file technical specifications (when `--advanced` is used)
245
247
 
246
248
  ---
247
249
 
@@ -284,13 +286,32 @@ human-readable and can be edited by hand if you prefer.
284
286
  4. **Guide generation** — all of the above is composed into a
285
287
  structured Markdown developer guide.
286
288
  5. **Advanced specs (optional)** — when `--advanced` is used, individual
287
- per-file technical specifications are generated and stored in `docs/specs/`.
289
+ per-file technical specifications are generated and stored in `specs_<project>_<timestamp>/`.
288
290
 
289
291
  Each step is cached by SHA-256 hash of the input. When you re-run on an
290
292
  unchanged project, only the guide is regenerated.
291
293
 
292
294
  ---
293
295
 
296
+ ### Beyond documentation: spec-driven development
297
+
298
+ Because `--advanced` produces per-file technical specifications in Markdown
299
+ (`specs_<project>_<timestamp>/`), the output can be used as an implementation spec — not just
300
+ as documentation.
301
+
302
+ Feed `guide_<project>_<timestamp>.md` and the `specs_<project>_<timestamp>/` directory to an AI coding assistant and ask it to build the project from scratch. The result is a new codebase that follows the original architecture and interfaces. This makes Siftcode useful not only for understanding existing code, but also for:
303
+
304
+ - **Porting** a project to another language — the specs are language-agnostic
305
+ - **Rebuilding** a project on a clean foundation — same design, new implementation
306
+ - **Starting from a spec** — write or generate the specs first, then let the AI implement them
307
+
308
+ > **Note:** Specs are generated by an LLM and may contain inaccuracies
309
+ > (the 14B model occasionally invents files that don't exist). Review them
310
+ > before using them as an implementation spec. Larger models produce more
311
+ > accurate specs.
312
+
313
+ ---
314
+
294
315
  ## Development
295
316
 
296
317
  ```bash
@@ -310,6 +331,9 @@ ruff check src/ tests/
310
331
  # Type-check
311
332
  mypy src/
312
333
  ```
334
+ > Note: GUI tests require a display. Use xvfb-run in headless
335
+ environments; they are skipped automatically when no display is
336
+ available.
313
337
 
314
338
  ### Project layout
315
339
 
@@ -324,7 +348,7 @@ siftcode/
324
348
  │ │ ├── config.py # project configuration
325
349
  │ │ ├── llm.py # Ollama client
326
350
  │ │ ├── prompts.py # prompt templates
327
- │ │ └── manual.py # Advanced Technical Analysis & Specification Generator
351
+ │ │ └── manual.py # advanced specification generator
328
352
  │ └── gui/ # GTK4 front-end
329
353
  │ ├── app.py
330
354
  │ ├── window.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "siftcode"
7
- dynamic = ["version"] # <-- tämä korvaa version-kentän
7
+ dynamic = ["version"]
8
8
  description = "Local LLM-powered code analysis and developer guide generator"
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -19,17 +19,27 @@ classifiers = [
19
19
  "Intended Audience :: Developers",
20
20
  "Programming Language :: Python :: 3.11",
21
21
  "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
22
23
  "Topic :: Software Development :: Documentation",
23
24
  ]
24
25
 
26
+ # Core dependencies — the CLI only needs these.
25
27
  dependencies = [
26
28
  "requests>=2.31",
27
- "PyGObject>=3.46",
28
29
  "platformdirs>=4.0",
29
30
  "tomli-w>=1.0",
30
31
  ]
31
32
 
32
33
  [project.optional-dependencies]
34
+ # The GTK GUI is optional so that headless installs (servers, CI,
35
+ # Docker) don't need to build PyGObject's C dependencies.
36
+ #
37
+ # Requires libadwaita >= 1.5 at the system level (for Adw.AboutDialog).
38
+ # PyGObject does not control the installed libadwaita version — it's
39
+ # provided by the distribution's package manager.
40
+ gui = [
41
+ "PyGObject>=3.46",
42
+ ]
33
43
  dev = [
34
44
  "pytest>=7.4",
35
45
  "pytest-cov>=4.1",
@@ -55,7 +65,6 @@ version = {attr = "siftcode._version.__version__"}
55
65
  [tool.setuptools.packages.find]
56
66
  where = ["src"]
57
67
  include = ["siftcode*"]
58
- exclude = ["tests*"]
59
68
 
60
69
  [tool.setuptools.package-data]
61
70
  siftcode = [
@@ -0,0 +1,5 @@
1
+ """Siftcode — local LLM-powered code analysis."""
2
+
3
+ from siftcode._version import __version__
4
+
5
+ __all__ = ["__version__"]
@@ -7,9 +7,40 @@ version, name, author, license, and other identifying information.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- __version__ = "0.3.0"
10
+ from typing import Any
11
+
12
+
13
+ __all__ = [
14
+ "__version__",
15
+ "__version_info__",
16
+ "__release_date__",
17
+ "__status__",
18
+ "APP_ID",
19
+ "APP_NAME",
20
+ "APP_SLUG",
21
+ "APP_TAGLINE",
22
+ "APP_SUMMARY",
23
+ "AUTHOR_NAME",
24
+ "AUTHOR_EMAIL",
25
+ "COPYRIGHT",
26
+ "COPYRIGHT_YEAR",
27
+ "LICENSE_NAME",
28
+ "LICENSE_SPDX",
29
+ "LICENSE_URL",
30
+ "HOMEPAGE_URL",
31
+ "REPOSITORY_URL",
32
+ "ISSUES_URL",
33
+ "CHANGELOG_URL",
34
+ "DESCRIPTION",
35
+ "version_string",
36
+ "short_version",
37
+ "about_data",
38
+ ]
39
+
40
+
41
+ __version__ = "0.3.1"
11
42
  __version_info__ = tuple(int(p) for p in __version__.split(".") if p.isdigit())
12
- __release_date__ = "2026-10-16"
43
+ __release_date__ = "2026-10-21"
13
44
  __status__ = "beta"
14
45
 
15
46
  APP_ID = "io.codeberg.lahtis.Siftcode"
@@ -39,19 +70,32 @@ DESCRIPTION = (
39
70
  "Everything runs on your own hardware — no code leaves your machine."
40
71
  )
41
72
 
73
+
42
74
  def version_string() -> str:
75
+ """Return a human-readable version string, e.g. 'Siftcode 0.3.0 (beta)'."""
43
76
  return f"{APP_NAME} {__version__} ({__status__})"
44
77
 
78
+
45
79
  def short_version() -> str:
80
+ """Return just the version number, e.g. '0.3.0'."""
46
81
  return __version__
47
82
 
48
- def about_data() -> dict:
83
+
84
+ def about_data() -> dict[str, Any]:
85
+ """
86
+ Return a dictionary of metadata suitable for Gtk.AboutDialog.
87
+
88
+ The 'license_type' value is a Gtk.License enum name; 'gpl-3-0-later'
89
+ matches LICENSE_SPDX = 'GPL-3.0-or-later'. Using 'gpl-3-0' here would
90
+ incorrectly claim GPL-3.0-only, which is stricter than the project's
91
+ actual license.
92
+ """
49
93
  return {
50
94
  "application_name": APP_NAME,
51
95
  "application_icon": APP_ID,
52
96
  "version": __version__,
53
97
  "developer_name": AUTHOR_NAME,
54
- "license_type": "gpl-3-0",
98
+ "license_type": "gpl-3-0-later",
55
99
  "comments": DESCRIPTION,
56
100
  "website": HOMEPAGE_URL,
57
101
  "issue_url": ISSUES_URL,
@@ -6,6 +6,7 @@ Provides a thin layer over the core modules:
6
6
  - cache: analysis cache management
7
7
  - llm: Ollama client
8
8
  - analyzer: project analysis engine
9
+ - manual: advanced per-file specification generator
9
10
 
10
11
  No GTK dependencies.
11
12
  """
@@ -14,6 +15,7 @@ from __future__ import annotations
14
15
 
15
16
  import argparse
16
17
  import logging
18
+ import shutil
17
19
  import sys
18
20
  from datetime import datetime
19
21
  from pathlib import Path
@@ -86,6 +88,12 @@ def _resolve_project(name_or_path: str) -> ProjectConfig:
86
88
  f"hint: '{name_or_path}' is not a directory and no saved project "
87
89
  f"matches. Run '{APP_SLUG} list' to see saved projects."
88
90
  )
91
+ except ValueError as e:
92
+ # load_project now raises ValueError for malformed TOML files.
93
+ raise SystemExit(
94
+ f"error: saved project '{name_or_path}' is invalid: {e}\n"
95
+ f"hint: fix or delete the config file under {PROJECTS_DIR}"
96
+ )
89
97
 
90
98
 
91
99
  def _check_ollama(client: OllamaClient) -> None:
@@ -109,11 +117,18 @@ def _print_progress(progress) -> None:
109
117
  bar_width = 30
110
118
  filled = int(bar_width * progress.fraction)
111
119
  bar = "█" * filled + "░" * (bar_width - filled)
112
- print(
113
- f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}",
114
- end="\r" if progress.fraction < 1.0 else "\n",
115
- flush=True,
116
- )
120
+ msg = f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}"
121
+
122
+ cols = shutil.get_terminal_size().columns
123
+ if len(msg) > cols - 1:
124
+ msg = msg[: cols - 4] + "..."
125
+
126
+ # Progress bar goes to stderr: visible on screen, but never mixed
127
+ # into a piped/logged stdout stream. \033[K clears the previous line.
128
+ print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True)
129
+
130
+ if progress.fraction >= 1.0:
131
+ print(file=sys.stderr)
117
132
 
118
133
 
119
134
  def _human_size(num_bytes: int) -> str:
@@ -124,6 +139,43 @@ def _human_size(num_bytes: int) -> str:
124
139
  return f"{num_bytes:.1f} TB"
125
140
 
126
141
 
142
+ def _safe_stem(name: str) -> str:
143
+ """
144
+ Convert a project name into a filesystem-safe stem.
145
+
146
+ Replaces path separators, spaces, and other problematic characters
147
+ so that a project name like "my/project" doesn't create subdirectories
148
+ or fail on Windows.
149
+ """
150
+ safe = "".join(
151
+ c if c.isalnum() or c in ("-", "_", ".") else "_"
152
+ for c in name
153
+ ).strip("._")
154
+ return safe or "project"
155
+
156
+
157
+ def _stamp() -> str:
158
+ """Single timestamp used for all output files of one run."""
159
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
160
+
161
+
162
+ def _save_last_run(config: ProjectConfig) -> None:
163
+ """
164
+ Persist last_run after a successful analysis.
165
+
166
+ Failures are logged but never abort the command: the analysis
167
+ results are already on disk and more valuable than the timestamp.
168
+ """
169
+ config.last_run = datetime.now().isoformat(timespec="seconds")
170
+ try:
171
+ save_project(config)
172
+ except Exception as e:
173
+ logger.warning(
174
+ "Could not update last_run for project '%s': %s",
175
+ config.name, e,
176
+ )
177
+
178
+
127
179
  # ---------------------------------------------------------------------------
128
180
  # Commands
129
181
  # ---------------------------------------------------------------------------
@@ -142,14 +194,22 @@ def cmd_analyze(args: argparse.Namespace) -> int:
142
194
  if args.timeout:
143
195
  config.model.timeout = args.timeout
144
196
 
197
+ # Validate BEFORE persisting, so we never save a broken config.
198
+ config.validate()
199
+
200
+ # Persist (only if the project already exists as a saved project).
201
+ # A malformed existing TOML is overwritten with the validated config.
145
202
  try:
146
203
  load_project(config.name)
147
204
  save_project(config)
148
205
  logger.info(f"Updated saved project '{config.name}'")
149
206
  except FileNotFoundError:
150
207
  pass
151
-
152
- config.validate()
208
+ except ValueError as e:
209
+ logger.warning(
210
+ "Existing project config is invalid and will be overwritten: %s", e,
211
+ )
212
+ save_project(config)
153
213
 
154
214
  output_dir = Path(args.output).expanduser() if args.output else Path.cwd()
155
215
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -162,56 +222,99 @@ def cmd_analyze(args: argparse.Namespace) -> int:
162
222
  logger.info(f"Model: {config.model.name}")
163
223
  logger.info(f"Context: {config.model.num_ctx} tokens")
164
224
 
165
- # JOS ADVANCED-LIPPU ON PÄÄLLÄ
166
- if getattr(args, "advanced", False):
167
- # Kerätään samat tiedostot kuin ProjectAnalyzer (käyttäen sen sääntöjä)
168
- temp_analyzer = ProjectAnalyzer(config, client)
169
- files_to_analyze = temp_analyzer._collect_files()
225
+ # One timestamp for the whole run: findings/guide/specs all share it.
226
+ run_stamp = _stamp()
227
+ project_stem = _safe_stem(config.name)
228
+ is_tty = sys.stdout.isatty()
229
+
230
+ # ------------------------------------------------------------------
231
+ # Advanced per-file specification mode
232
+ # ------------------------------------------------------------------
233
+ if args.advanced:
234
+ analyzer = ProjectAnalyzer(config, client)
235
+ files_to_analyze = analyzer.collect_files()
170
236
 
171
237
  if not files_to_analyze:
172
- print("No Python files found for advanced specification generation.")
238
+ print("No analyzable files found for advanced specification generation.")
173
239
  return 0
174
240
 
175
241
  spec_gen = SpecGenerator(config=config, client=client)
176
- specs_output_dir = output_dir / f"specs_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}"
242
+ specs_output_dir = output_dir / f"specs_{project_stem}_{run_stamp}"
177
243
  specs_output_dir.mkdir(parents=True, exist_ok=True)
178
244
 
179
- print(f"Running advanced per-file technical specification for {len(files_to_analyze)} files...")
245
+ print(f"Running advanced per-file technical specification "
246
+ f"for {len(files_to_analyze)} files...")
180
247
 
181
248
  def progress_cb(current: int, total: int, path: Path) -> None:
182
- rel = path.relative_to(config.path).as_posix()
249
+ try:
250
+ rel = path.relative_to(config.path).as_posix()
251
+ except ValueError:
252
+ rel = path.name
183
253
  if len(rel) > 35:
184
254
  rel = "..." + rel[-32:]
185
- fraction = current / total
255
+ fraction = current / total if total else 1.0
186
256
  bar_width = 20
187
257
  filled = int(bar_width * fraction)
188
258
  bar = "█" * filled + "░" * (bar_width - filled)
189
259
  msg = f" [{bar}] {fraction * 100:5.1f}% ({current}/{total}) {rel}"
190
- end_char = "\n" if fraction >= 1.0 else ""
191
- print(f"\r\033[K{msg}", end=end_char, flush=True)
260
+ if is_tty:
261
+ print(f"\r\033[K{msg}", end="", flush=True)
262
+ if fraction >= 1.0:
263
+ print()
264
+ else:
265
+ print(msg, flush=True)
192
266
 
193
267
  try:
194
- specs_dict = spec_gen.generate_project_specs(files_to_analyze, progress_callback=progress_cb)
268
+ specs_dict = spec_gen.generate_project_specs(
269
+ files_to_analyze, progress_callback=progress_cb,
270
+ )
195
271
  except KeyboardInterrupt:
196
272
  print("\n\ninterrupted by user", file=sys.stderr)
197
273
  return 130
274
+ except LLMError as e:
275
+ print(f"\nerror: {e}", file=sys.stderr)
276
+ return 1
198
277
  except Exception as e:
199
278
  print(f"\nerror: {e}", file=sys.stderr)
200
279
  return 1
201
-
202
- # Tallennetaan tiedostot levylle
280
+
281
+ # Write specs to disk. generate_project_specs already returns
282
+ # only successfully processed files, so specs_dict may be
283
+ # smaller than files_to_analyze. Count the difference and
284
+ # surface it to the user.
203
285
  for rel_path, markdown_content in specs_dict.items():
286
+ # Guard against rel_path containing '..' segments, which
287
+ # would let the write escape specs_output_dir.
288
+ safe_rel = Path(rel_path)
289
+ if safe_rel.is_absolute() or ".." in safe_rel.parts:
290
+ logger.warning(
291
+ "Skipping unsafe output path: %s", rel_path,
292
+ )
293
+ continue
204
294
  target_file = specs_output_dir / f"{rel_path}.md"
205
295
  target_file.parent.mkdir(parents=True, exist_ok=True)
206
296
  target_file.write_text(markdown_content, encoding="utf-8")
207
297
 
298
+ _save_last_run(config)
299
+
300
+ expected = len(files_to_analyze)
301
+ written = len(specs_dict)
302
+ missing = expected - written
303
+
208
304
  print()
209
- print(f"✓ Advanced specification generation complete")
305
+ print("✓ Advanced specification generation complete")
210
306
  print(f" Output directory: {specs_output_dir}")
211
- print(f" Files processed: {len(specs_dict)}")
307
+ print(f" Files processed: {written}")
308
+ if missing > 0:
309
+ print(
310
+ f"{missing} file(s) failed — see warnings above",
311
+ file=sys.stderr,
312
+ )
212
313
  return 0
213
314
 
214
- # NORMAALI ANALYYSI
315
+ # ------------------------------------------------------------------
316
+ # Normal analysis
317
+ # ------------------------------------------------------------------
215
318
  analyzer = ProjectAnalyzer(config, client)
216
319
 
217
320
  try:
@@ -224,14 +327,16 @@ def cmd_analyze(args: argparse.Namespace) -> int:
224
327
  print(f"\nerror: {e}", file=sys.stderr)
225
328
  return 1
226
329
 
227
- findings_path = output_dir / f"findings_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}.json"
228
- guide_path = output_dir / f"guide_{config.path.name}_{datetime.now():%Y%m%d_%H%M%S}.md"
330
+ findings_path = output_dir / f"findings_{project_stem}_{run_stamp}.json"
331
+ guide_path = output_dir / f"guide_{project_stem}_{run_stamp}.md"
229
332
 
230
333
  analyzer.findings.save(findings_path)
231
334
  guide_path.write_text(analyzer.findings.guide, encoding="utf-8")
232
335
 
336
+ _save_last_run(config)
337
+
233
338
  print()
234
- print(f"✓ Analysis complete")
339
+ print("✓ Analysis complete")
235
340
  print(f" Findings: {findings_path}")
236
341
  print(f" Guide: {guide_path}")
237
342
  print(f" Errors: {len(analyzer.findings.errors)}")
@@ -264,6 +369,16 @@ def cmd_add(args: argparse.Namespace) -> int:
264
369
  return 1
265
370
  except FileNotFoundError:
266
371
  pass
372
+ except ValueError as e:
373
+ # Malformed existing config — overwrite it (the user asked to
374
+ # add this project, and --force semantics apply).
375
+ if not args.force:
376
+ print(
377
+ f"error: existing config for '{name}' is invalid: {e}\n"
378
+ f"hint: use --force to overwrite it",
379
+ file=sys.stderr,
380
+ )
381
+ return 1
267
382
 
268
383
  path = save_project(config)
269
384
  print(f"✓ Saved project '{name}'")
@@ -300,6 +415,12 @@ def cmd_show(args: argparse.Namespace) -> int:
300
415
  except FileNotFoundError:
301
416
  print(f"error: no saved project named '{args.project}'", file=sys.stderr)
302
417
  return 1
418
+ except ValueError as e:
419
+ print(
420
+ f"error: saved project '{args.project}' is invalid: {e}",
421
+ file=sys.stderr,
422
+ )
423
+ return 1
303
424
 
304
425
  print(f"Project: {config.name}")
305
426
  print(f" Path: {config.path}")