codedocent 0.2.1__tar.gz → 0.4.0__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 (38) hide show
  1. codedocent-0.4.0/PKG-INFO +69 -0
  2. codedocent-0.4.0/README.md +59 -0
  3. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/analyzer.py +75 -18
  4. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/cli.py +14 -4
  5. codedocent-0.4.0/codedocent/editor.py +173 -0
  6. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/gui.py +28 -9
  7. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/parser.py +20 -2
  8. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/quality.py +7 -55
  9. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/renderer.py +6 -4
  10. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/scanner.py +10 -2
  11. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/server.py +179 -37
  12. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/templates/interactive.html +24 -15
  13. codedocent-0.4.0/codedocent.egg-info/PKG-INFO +69 -0
  14. {codedocent-0.2.1 → codedocent-0.4.0}/pyproject.toml +3 -2
  15. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_analyzer.py +130 -125
  16. codedocent-0.4.0/tests/test_editor.py +278 -0
  17. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_parser.py +18 -0
  18. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_renderer.py +40 -0
  19. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_scanner.py +22 -0
  20. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_server.py +270 -13
  21. codedocent-0.2.1/PKG-INFO +0 -16
  22. codedocent-0.2.1/README.md +0 -183
  23. codedocent-0.2.1/codedocent/editor.py +0 -89
  24. codedocent-0.2.1/codedocent.egg-info/PKG-INFO +0 -16
  25. codedocent-0.2.1/tests/test_editor.py +0 -113
  26. {codedocent-0.2.1 → codedocent-0.4.0}/LICENSE +0 -0
  27. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/__init__.py +0 -0
  28. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/__main__.py +0 -0
  29. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/ollama_utils.py +0 -0
  30. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent/templates/base.html +0 -0
  31. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/SOURCES.txt +0 -0
  32. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/dependency_links.txt +0 -0
  33. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/entry_points.txt +0 -0
  34. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/requires.txt +4 -4
  35. {codedocent-0.2.1 → codedocent-0.4.0}/codedocent.egg-info/top_level.txt +0 -0
  36. {codedocent-0.2.1 → codedocent-0.4.0}/setup.cfg +0 -0
  37. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_cli.py +0 -0
  38. {codedocent-0.2.1 → codedocent-0.4.0}/tests/test_gui.py +0 -0
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.1
2
+ Name: codedocent
3
+ Version: 0.4.0
4
+ Summary: Code visualization for non-programmers
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Provides-Extra: dev
9
+ License-File: LICENSE
10
+
11
+ # codedocent
12
+
13
+ <img width="1658" height="2158" alt="Screenshot_2026-02-09_13-17-06" src="https://github.com/user-attachments/assets/ff097ead-69ec-4618-b7b7-2b99c60ac57e" />
14
+
15
+ **Code visualization for non-programmers.**
16
+
17
+ A docent is a guide who explains things to people who aren't experts. Codedocent does that for code.
18
+
19
+ ## The problem
20
+
21
+ You're staring at a codebase you didn't write — maybe thousands of files across dozens of directories — and you need to understand what it does. Reading every file isn't realistic. You need a way to visualize the code structure, get a high-level map of what's where, and drill into the parts that matter without losing context.
22
+
23
+ Codedocent parses the codebase into a navigable, visual block structure and explains each piece in plain English. It's an AI code analysis tool that runs entirely on your machine — no API keys, no cloud, no data leaving your laptop. Point it at any codebase and get a structural overview you can explore interactively, understand quickly, and share as a static HTML file.
24
+
25
+ ## Who this is for
26
+
27
+ - **Developers onboarding onto an unfamiliar codebase** — get oriented in minutes instead of days
28
+ - **Non-programmers** (managers, designers, PMs) who need to understand what code does without reading it
29
+ - **Solo developers inheriting legacy code** — map out the structure before making changes
30
+ - **Code reviewers** who want a high-level overview before diving into details
31
+ - **Security reviewers** who need a structural map of an application
32
+ - **Students** learning to read and navigate real-world codebases
33
+
34
+ ## What you see
35
+
36
+ Nested, color-coded blocks representing directories, files, classes, and functions — the entire structure of a codebase laid out visually. Each block shows a plain English summary, a pseudocode translation, and quality warnings (green/yellow/red). Click any block to drill down; breadcrumbs navigate you back up. You can export code from any block or paste replacement code back into the source file. All AI runs locally through Ollama — nothing leaves your machine.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install codedocent
42
+ ```
43
+
44
+ Requires Python 3.10+ and [Ollama](https://ollama.com) running locally for AI features. Works without AI too (`--no-ai`).
45
+
46
+ ## Quick start
47
+
48
+ ```bash
49
+ codedocent # setup wizard — walks you through everything
50
+ codedocent /path/to/code # interactive mode (recommended)
51
+ codedocent /path/to/code --full # full analysis, static HTML output
52
+ codedocent --gui # graphical launcher
53
+ ```
54
+
55
+ ## How it works
56
+
57
+ Parses code structure with tree-sitter, scores quality with static analysis, and sends individual blocks to a local Ollama model for plain English summaries and pseudocode. Interactive mode analyzes on click — typically 1-2 seconds per block. Full mode analyzes everything upfront into a self-contained HTML file you can share.
58
+
59
+ ## Why local
60
+
61
+ All AI processing runs through Ollama on your machine. Your code is never uploaded, transmitted, or stored anywhere external. No API keys, no accounts, no cloud services. This matters when you're working with proprietary code, client projects, or anything you can't share — codedocent works fully air-gapped. The `--no-ai` mode removes the AI dependency entirely while keeping the structural visualization and quality scoring.
62
+
63
+ ## Supported languages
64
+
65
+ Full AST parsing for Python and JavaScript/TypeScript (functions, classes, methods, imports). File-level detection for 23 extensions including C, C++, Rust, Go, Java, Ruby, PHP, Swift, Kotlin, Scala, HTML, CSS, and config formats.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,59 @@
1
+ # codedocent
2
+
3
+ <img width="1658" height="2158" alt="Screenshot_2026-02-09_13-17-06" src="https://github.com/user-attachments/assets/ff097ead-69ec-4618-b7b7-2b99c60ac57e" />
4
+
5
+ **Code visualization for non-programmers.**
6
+
7
+ A docent is a guide who explains things to people who aren't experts. Codedocent does that for code.
8
+
9
+ ## The problem
10
+
11
+ You're staring at a codebase you didn't write — maybe thousands of files across dozens of directories — and you need to understand what it does. Reading every file isn't realistic. You need a way to visualize the code structure, get a high-level map of what's where, and drill into the parts that matter without losing context.
12
+
13
+ Codedocent parses the codebase into a navigable, visual block structure and explains each piece in plain English. It's an AI code analysis tool that runs entirely on your machine — no API keys, no cloud, no data leaving your laptop. Point it at any codebase and get a structural overview you can explore interactively, understand quickly, and share as a static HTML file.
14
+
15
+ ## Who this is for
16
+
17
+ - **Developers onboarding onto an unfamiliar codebase** — get oriented in minutes instead of days
18
+ - **Non-programmers** (managers, designers, PMs) who need to understand what code does without reading it
19
+ - **Solo developers inheriting legacy code** — map out the structure before making changes
20
+ - **Code reviewers** who want a high-level overview before diving into details
21
+ - **Security reviewers** who need a structural map of an application
22
+ - **Students** learning to read and navigate real-world codebases
23
+
24
+ ## What you see
25
+
26
+ Nested, color-coded blocks representing directories, files, classes, and functions — the entire structure of a codebase laid out visually. Each block shows a plain English summary, a pseudocode translation, and quality warnings (green/yellow/red). Click any block to drill down; breadcrumbs navigate you back up. You can export code from any block or paste replacement code back into the source file. All AI runs locally through Ollama — nothing leaves your machine.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install codedocent
32
+ ```
33
+
34
+ Requires Python 3.10+ and [Ollama](https://ollama.com) running locally for AI features. Works without AI too (`--no-ai`).
35
+
36
+ ## Quick start
37
+
38
+ ```bash
39
+ codedocent # setup wizard — walks you through everything
40
+ codedocent /path/to/code # interactive mode (recommended)
41
+ codedocent /path/to/code --full # full analysis, static HTML output
42
+ codedocent --gui # graphical launcher
43
+ ```
44
+
45
+ ## How it works
46
+
47
+ Parses code structure with tree-sitter, scores quality with static analysis, and sends individual blocks to a local Ollama model for plain English summaries and pseudocode. Interactive mode analyzes on click — typically 1-2 seconds per block. Full mode analyzes everything upfront into a self-contained HTML file you can share.
48
+
49
+ ## Why local
50
+
51
+ All AI processing runs through Ollama on your machine. Your code is never uploaded, transmitted, or stored anywhere external. No API keys, no accounts, no cloud services. This matters when you're working with proprietary code, client projects, or anything you can't share — codedocent works fully air-gapped. The `--no-ai` mode removes the AI dependency entirely while keeping the structural visualization and quality scoring.
52
+
53
+ ## Supported languages
54
+
55
+ Full AST parsing for Python and JavaScript/TypeScript (functions, classes, methods, imports). File-level detection for 23 extensions including C, C++, Rust, Go, Java, Ruby, PHP, Swift, Kotlin, Scala, HTML, CSS, and config formats.
56
+
57
+ ## License
58
+
59
+ MIT
@@ -7,6 +7,7 @@ import json
7
7
  import os
8
8
  import re
9
9
  import sys
10
+ import tempfile
10
11
  import threading
11
12
  import time
12
13
  from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -28,6 +29,14 @@ MAX_SOURCE_LINES = 200
28
29
  MIN_LINES_FOR_AI = 3
29
30
 
30
31
 
32
+ def _md5(data: bytes) -> "hashlib._Hash":
33
+ """Create an MD5 hash, tolerating FIPS-mode Python builds."""
34
+ try:
35
+ return hashlib.md5(data, usedforsecurity=False)
36
+ except TypeError:
37
+ return hashlib.md5(data) # nosec B324
38
+
39
+
31
40
  def _count_nodes(node: CodeNode) -> int:
32
41
  """Recursive count of all nodes in tree."""
33
42
  return 1 + sum(_count_nodes(c) for c in node.children)
@@ -103,15 +112,34 @@ def _parse_ai_response(text: str) -> tuple[str, str]:
103
112
  return summary, pseudocode
104
113
 
105
114
 
115
+ _AI_TIMEOUT = 120
116
+
117
+
106
118
  def _summarize_with_ai(
107
119
  node: CodeNode, model: str
108
- ) -> tuple[str, str]:
109
- """Call ollama to get summary and pseudocode for a node."""
120
+ ) -> tuple[str, str] | None:
121
+ """Call ollama to get summary and pseudocode for a node.
122
+
123
+ Returns ``None`` if the AI call times out.
124
+ """
110
125
  prompt = _build_prompt(node, model)
111
- response = ollama.chat(
112
- model=model, messages=[{"role": "user", "content": prompt}]
126
+ pool = ThreadPoolExecutor(max_workers=1)
127
+ future = pool.submit(
128
+ ollama.chat,
129
+ model=model,
130
+ messages=[{"role": "user", "content": prompt}],
113
131
  )
114
- raw = response.message.content or "" # pylint: disable=no-member
132
+ try:
133
+ response = future.result(timeout=_AI_TIMEOUT)
134
+ except TimeoutError:
135
+ future.cancel()
136
+ pool.shutdown(wait=False, cancel_futures=True)
137
+ return None
138
+ pool.shutdown(wait=False)
139
+ msg = getattr(response, "message", None)
140
+ if msg is None:
141
+ raise ValueError("Unexpected Ollama response format")
142
+ raw = getattr(msg, "content", None) or ""
115
143
  raw = _strip_think_tags(raw)
116
144
  # Garbage response fallback: empty or very short after stripping
117
145
  if not raw or len(raw) < 10:
@@ -130,9 +158,7 @@ def _summarize_with_ai(
130
158
 
131
159
  def _cache_key(node: CodeNode) -> str:
132
160
  """Generate a cache key based on filepath, name, and source hash."""
133
- source_hash = hashlib.md5(
134
- node.source.encode(), usedforsecurity=False
135
- ).hexdigest()
161
+ source_hash = _md5(node.source.encode()).hexdigest()
136
162
  return f"{node.filepath}::{node.name}::{source_hash}"
137
163
 
138
164
 
@@ -149,12 +175,34 @@ def _load_cache(path: str) -> dict:
149
175
 
150
176
 
151
177
  def _save_cache(path: str, data: dict) -> None:
152
- """Save cache to JSON file."""
178
+ """Save cache to JSON file atomically."""
179
+ parent = os.path.dirname(os.path.abspath(path))
180
+ tmp_path: str | None = None
153
181
  try:
154
- with open(path, "w", encoding="utf-8") as f:
155
- json.dump(data, f, indent=2)
182
+ fd = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with # noqa: E501
183
+ mode="w", encoding="utf-8",
184
+ dir=parent, delete=False, suffix=".tmp",
185
+ )
186
+ tmp_path = fd.name
187
+ try:
188
+ json.dump(data, fd, indent=2)
189
+ fd.flush()
190
+ os.fsync(fd.fileno())
191
+ finally:
192
+ fd.close()
193
+ os.replace(tmp_path, path)
194
+ tmp_path = None # success — don't clean up
156
195
  except OSError as e:
157
- print(f"Warning: could not save cache: {e}", file=sys.stderr)
196
+ print(
197
+ f"Warning: could not save cache: {e}",
198
+ file=sys.stderr,
199
+ )
200
+ finally:
201
+ if tmp_path is not None:
202
+ try:
203
+ os.unlink(tmp_path)
204
+ except OSError:
205
+ pass
158
206
 
159
207
 
160
208
  # ---------------------------------------------------------------------------
@@ -171,9 +219,7 @@ def assign_node_ids(root: CodeNode) -> dict[str, CodeNode]:
171
219
 
172
220
  def _walk(node: CodeNode, path_parts: list[str]) -> None:
173
221
  key = "::".join(path_parts)
174
- node_id = hashlib.md5(
175
- key.encode(), usedforsecurity=False
176
- ).hexdigest()[:12]
222
+ node_id = _md5(key.encode()).hexdigest()[:12]
177
223
  node.node_id = node_id
178
224
  lookup[node_id] = node
179
225
  for child in node.children:
@@ -228,12 +274,19 @@ def analyze_single_node(node: CodeNode, model: str, cache_dir: str) -> None:
228
274
  return
229
275
 
230
276
  try:
231
- summary, pseudocode = _summarize_with_ai(node, model)
277
+ result = _summarize_with_ai(node, model)
278
+ if result is None:
279
+ node.summary = "Summary timed out"
280
+ return
281
+ summary, pseudocode = result
232
282
  node.summary = summary
233
283
  node.pseudocode = pseudocode
234
284
  cache["entries"][key] = {"summary": summary, "pseudocode": pseudocode}
235
285
  _save_cache(cache_path, cache)
236
- except (ConnectionError, RuntimeError, ValueError, OSError) as e:
286
+ except (
287
+ ConnectionError, RuntimeError, ValueError,
288
+ OSError, AttributeError, TypeError,
289
+ ) as e:
237
290
  node.summary = f"Summary generation failed: {e}"
238
291
 
239
292
 
@@ -331,7 +384,11 @@ def _run_ai_batch(
331
384
  return
332
385
  _progress(f"Analyzing {node.name}")
333
386
  try:
334
- summary, pseudocode = _summarize_with_ai(node, model)
387
+ result = _summarize_with_ai(node, model)
388
+ if result is None:
389
+ node.summary = "Summary timed out"
390
+ return
391
+ summary, pseudocode = result
335
392
  with cache_lock:
336
393
  node.summary = summary
337
394
  node.pseudocode = pseudocode
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import os
7
+ import sys
7
8
 
8
9
  from codedocent.ollama_utils import check_ollama, fetch_ollama_models
9
10
  from codedocent.parser import CodeNode, parse_directory
@@ -15,6 +16,15 @@ _check_ollama = check_ollama
15
16
  _fetch_ollama_models = fetch_ollama_models
16
17
 
17
18
 
19
+ def _safe_input(prompt: str) -> str:
20
+ """Wrap input() to handle EOF gracefully."""
21
+ try:
22
+ return input(prompt)
23
+ except EOFError:
24
+ print("\nInput closed. Exiting.")
25
+ sys.exit(0)
26
+
27
+
18
28
  def print_tree(node: CodeNode, indent: int = 0) -> None:
19
29
  """Print a text representation of the code tree."""
20
30
  prefix = " " * indent
@@ -44,7 +54,7 @@ def print_tree(node: CodeNode, indent: int = 0) -> None:
44
54
  def _ask_folder() -> str:
45
55
  """Prompt for a valid folder path, re-asking on invalid input."""
46
56
  while True:
47
- path = input("What folder do you want to analyze? ").strip()
57
+ path = _safe_input("What folder do you want to analyze? ").strip()
48
58
  path = os.path.expanduser(path)
49
59
  if os.path.isdir(path):
50
60
  file_count = len(list(scan_directory(path)))
@@ -55,7 +65,7 @@ def _ask_folder() -> str:
55
65
 
56
66
  def _ask_no_ai_fallback() -> bool:
57
67
  """Ask user whether to continue without AI. Returns True for no-ai."""
58
- fallback = input("Continue without AI? [Y/n]: ").strip().lower()
68
+ fallback = _safe_input("Continue without AI? [Y/n]: ").strip().lower()
59
69
  if fallback in ("", "y", "yes"):
60
70
  return True
61
71
  raise SystemExit(0)
@@ -66,7 +76,7 @@ def _pick_model(models: list[str]) -> str:
66
76
  print("Available models:")
67
77
  for i, m in enumerate(models, 1):
68
78
  print(f" {i}. {m}")
69
- choice = input("Which model? [1]: ").strip()
79
+ choice = _safe_input("Which model? [1]: ").strip()
70
80
  if choice == "":
71
81
  return models[0]
72
82
  try:
@@ -106,7 +116,7 @@ def _run_wizard() -> argparse.Namespace:
106
116
  print(" 1. Interactive \u2014 browse in browser [default]")
107
117
  print(" 2. Full export \u2014 analyze everything, save HTML")
108
118
  print(" 3. Text tree \u2014 plain text in terminal")
109
- mode_choice = input("Choice [1]: ").strip()
119
+ mode_choice = _safe_input("Choice [1]: ").strip()
110
120
 
111
121
  text = mode_choice == "3"
112
122
  full = mode_choice == "2"
@@ -0,0 +1,173 @@
1
+ """Code replacement: write modified source back into a file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from datetime import datetime
9
+
10
+
11
+ def _read_and_validate(
12
+ filepath: str, start_line: int, end_line: int,
13
+ ) -> tuple[list[str] | None, str | None, tuple[int, int], str]:
14
+ """Read *filepath* and validate the line range.
15
+
16
+ Returns ``(lines, None, (mtime_ns, size), line_ending)`` on success,
17
+ or ``(None, error_message, (0, 0), "\\n")`` on failure.
18
+ """
19
+ if not os.path.isfile(filepath):
20
+ return (None, f"File not found: {filepath}", (0, 0), "\n")
21
+ if (
22
+ not isinstance(start_line, int)
23
+ or not isinstance(end_line, int)
24
+ or start_line < 1
25
+ or end_line < 1
26
+ or start_line > end_line
27
+ ):
28
+ return (
29
+ None,
30
+ f"Invalid line range: {start_line}-{end_line}",
31
+ (0, 0), "\n",
32
+ )
33
+ try:
34
+ with open(filepath, "rb") as f:
35
+ raw = f.read()
36
+ _stat = os.stat(filepath)
37
+ file_stamp = (_stat.st_mtime_ns, _stat.st_size)
38
+ text = raw.decode("utf-8")
39
+ except UnicodeDecodeError:
40
+ return (None, "File is not valid UTF-8 text", (0, 0), "\n")
41
+
42
+ # Detect line ending style: CRLF vs LF
43
+ crlf_count = text.count("\r\n")
44
+ lf_count = text.count("\n") - crlf_count
45
+ line_ending = "\r\n" if crlf_count > lf_count else "\n"
46
+
47
+ lines = text.splitlines(True)
48
+ if end_line > len(lines):
49
+ return (
50
+ None,
51
+ f"end_line {end_line} exceeds file length"
52
+ f" ({len(lines)} lines)",
53
+ (0, 0), "\n",
54
+ )
55
+ return (lines, None, file_stamp, line_ending)
56
+
57
+
58
+ def _write_with_backup(
59
+ filepath: str, lines: list[str], file_stamp: tuple[int, int],
60
+ ) -> None:
61
+ """Create a timestamped ``.bak`` backup and write *lines* back.
62
+
63
+ *file_stamp* is ``(st_mtime_ns, st_size)`` from the initial read.
64
+ Raises ``OSError`` if the file was modified externally since the
65
+ last read, if the backup could not be created, or on write failure.
66
+ """
67
+ _stat = os.stat(filepath)
68
+ if (_stat.st_mtime_ns, _stat.st_size) != file_stamp:
69
+ raise OSError("File was modified externally since last read")
70
+
71
+ now = datetime.now()
72
+ backup_path = (
73
+ filepath + ".bak."
74
+ + now.strftime("%Y%m%dT%H%M%S") + f".{now.microsecond:06d}"
75
+ )
76
+
77
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
78
+ if hasattr(os, "O_NOFOLLOW"):
79
+ flags |= os.O_NOFOLLOW
80
+ try:
81
+ fd_bak = os.open(backup_path, flags, 0o600)
82
+ os.close(fd_bak)
83
+ except FileExistsError:
84
+ for i in range(1, 100):
85
+ candidate = backup_path + f".{i}"
86
+ try:
87
+ fd_bak = os.open(candidate, flags, 0o600)
88
+ os.close(fd_bak)
89
+ backup_path = candidate
90
+ break
91
+ except FileExistsError:
92
+ continue
93
+ else:
94
+ raise OSError("Cannot create unique backup path") from None
95
+
96
+ shutil.copy2(filepath, backup_path)
97
+
98
+ if not os.path.exists(backup_path):
99
+ raise OSError(
100
+ "Backup creation failed: "
101
+ f"{backup_path} does not exist"
102
+ )
103
+
104
+ parent_dir = os.path.dirname(os.path.abspath(filepath))
105
+ fd = tempfile.NamedTemporaryFile( # pylint: disable=consider-using-with
106
+ mode="wb",
107
+ dir=parent_dir, delete=False, suffix=".tmp",
108
+ )
109
+ tmp_path = fd.name
110
+ try:
111
+ for line in lines:
112
+ fd.write(line.encode("utf-8"))
113
+ fd.flush()
114
+ os.fsync(fd.fileno())
115
+ fd.close()
116
+ orig_mode = os.stat(filepath).st_mode
117
+ os.chmod(tmp_path, orig_mode)
118
+ os.replace(tmp_path, filepath)
119
+ except BaseException:
120
+ fd.close()
121
+ try:
122
+ os.unlink(tmp_path)
123
+ except OSError:
124
+ pass
125
+ raise
126
+
127
+
128
+ def replace_block_source(
129
+ filepath: str,
130
+ start_line: int,
131
+ end_line: int,
132
+ new_source: str,
133
+ ) -> dict:
134
+ """Replace lines *start_line* through *end_line* (1-indexed, inclusive).
135
+
136
+ Creates a timestamped ``.bak`` backup before writing. Returns a
137
+ result dict with ``success``, ``lines_before``, ``lines_after`` on
138
+ success, or ``success=False`` and ``error`` on failure.
139
+ """
140
+ if not isinstance(new_source, str):
141
+ return {"success": False, "error": "new_source must be a string"}
142
+
143
+ lines, error, file_stamp, line_ending = _read_and_validate(
144
+ filepath, start_line, end_line,
145
+ )
146
+ if lines is None:
147
+ return {"success": False, "error": error}
148
+
149
+ old_count = end_line - start_line + 1
150
+
151
+ try:
152
+ # Build replacement lines
153
+ if new_source == "":
154
+ new_lines: list[str] = []
155
+ else:
156
+ raw_lines = new_source.splitlines(True)
157
+ new_lines = [
158
+ ln.rstrip("\r\n") + line_ending for ln in raw_lines
159
+ ]
160
+
161
+ new_count = len(new_lines)
162
+ lines[start_line - 1:end_line] = new_lines
163
+
164
+ _write_with_backup(filepath, lines, file_stamp)
165
+
166
+ return {
167
+ "success": True,
168
+ "lines_before": old_count,
169
+ "lines_after": new_count,
170
+ }
171
+
172
+ except OSError as exc:
173
+ return {"success": False, "error": str(exc)}
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import subprocess # nosec B404
6
6
  import sys
7
+ import threading
7
8
 
8
9
  from codedocent.ollama_utils import check_ollama, fetch_ollama_models
9
10
 
@@ -41,19 +42,35 @@ def _create_folder_row(frame: ttk.Frame) -> tk.StringVar:
41
42
  return folder_var
42
43
 
43
44
 
44
- def _create_model_row(frame: ttk.Frame) -> tk.StringVar:
45
+ def _create_model_row(
46
+ frame: ttk.Frame, root: tk.Tk,
47
+ ) -> tk.StringVar:
45
48
  """Create the model-dropdown row and return the StringVar."""
46
49
  ttk.Label(frame, text="Model:").grid(
47
50
  row=2, column=0, sticky="w", pady=(12, 4),
48
51
  )
49
- ollama_ok = _check_ollama()
50
- models = _fetch_ollama_models() if ollama_ok else []
51
- model_values = models if models else ["No AI"]
52
- model_var = tk.StringVar(value=model_values[0])
53
- ttk.Combobox(
54
- frame, textvariable=model_var, values=model_values,
52
+ model_var = tk.StringVar(value="Checking...")
53
+ combo = ttk.Combobox(
54
+ frame, textvariable=model_var, values=["Checking..."],
55
55
  state="readonly", width=37,
56
- ).grid(row=3, column=0, columnspan=2, sticky="ew")
56
+ )
57
+ combo.grid(row=3, column=0, columnspan=2, sticky="ew")
58
+
59
+ def _bg_fetch() -> None:
60
+ try:
61
+ ollama_ok = _check_ollama()
62
+ models = _fetch_ollama_models() if ollama_ok else []
63
+ except Exception: # pylint: disable=broad-exception-caught
64
+ models = []
65
+ model_values = models if models else ["No AI"]
66
+
67
+ def _update_ui() -> None:
68
+ combo["values"] = model_values
69
+ model_var.set(model_values[0])
70
+
71
+ root.after(0, _update_ui)
72
+
73
+ threading.Thread(target=_bg_fetch, daemon=True).start()
57
74
  return model_var
58
75
 
59
76
 
@@ -90,6 +107,8 @@ def _create_go_button(
90
107
  cmd = [sys.executable, "-m", "codedocent", folder]
91
108
 
92
109
  selected_model = model_var.get()
110
+ if selected_model == "Checking...":
111
+ return
93
112
  if selected_model == "No AI":
94
113
  cmd.append("--no-ai")
95
114
  else:
@@ -119,7 +138,7 @@ def _build_gui() -> None:
119
138
  frame.grid(row=0, column=0, sticky="nsew")
120
139
 
121
140
  folder_var = _create_folder_row(frame)
122
- model_var = _create_model_row(frame)
141
+ model_var = _create_model_row(frame, root)
123
142
  mode_var = _create_mode_row(frame)
124
143
  _create_go_button(frame, root, folder_var, model_var, mode_var)
125
144
 
@@ -66,6 +66,19 @@ _METHOD_TYPES: dict[str, dict[str, str]] = {
66
66
  }
67
67
 
68
68
 
69
+ def _unwrap_exports(root_node) -> list:
70
+ """Yield top-level children, unwrapping export_statement nodes."""
71
+ result = []
72
+ for child in root_node.children:
73
+ if child.type == "export_statement":
74
+ for inner in child.children:
75
+ if inner.type not in ("export", "default", ",", ";"):
76
+ result.append(inner)
77
+ else:
78
+ result.append(child)
79
+ return result
80
+
81
+
69
82
  def _rules_for(language: str) -> dict[str, tuple[str, str]]:
70
83
  """Return AST extraction rules for the given language."""
71
84
  if language == "python":
@@ -126,7 +139,7 @@ def _extract_arrow_functions(root_node, language: str) -> list[CodeNode]:
126
139
  if language not in ("javascript", "typescript", "tsx"):
127
140
  return []
128
141
  results: list[CodeNode] = []
129
- for child in root_node.children:
142
+ for child in _unwrap_exports(root_node):
130
143
  if child.type != "lexical_declaration":
131
144
  continue
132
145
  for decl in child.children:
@@ -210,7 +223,12 @@ def _extract_top_level_nodes(
210
223
  ) -> list[CodeNode]:
211
224
  """Walk AST top-level children, create CodeNodes, attach methods."""
212
225
  children: list[CodeNode] = []
213
- for child in root_node.children:
226
+ top_children = (
227
+ _unwrap_exports(root_node)
228
+ if language in ("javascript", "typescript", "tsx")
229
+ else root_node.children
230
+ )
231
+ for child in top_children:
214
232
  if child.type in rules:
215
233
  our_type, name_child = rules[child.type]
216
234
  node = CodeNode(