SourceIndex 0.1.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.
Files changed (47) hide show
  1. sourceindex/__init__.py +30 -0
  2. sourceindex/build/__init__.py +592 -0
  3. sourceindex/build/indexer.py +403 -0
  4. sourceindex/build/linerange/__init__.py +24 -0
  5. sourceindex/build/linerange/python_ast.py +155 -0
  6. sourceindex/build/linerange/treesitter.py +397 -0
  7. sourceindex/build/prompts.py +76 -0
  8. sourceindex/build/state.py +261 -0
  9. sourceindex/build/walker.py +94 -0
  10. sourceindex/claudecode/__init__.py +0 -0
  11. sourceindex/claudecode/savings.py +325 -0
  12. sourceindex/claudecode/savings_summary.py +88 -0
  13. sourceindex/claudecode/statusline.py +116 -0
  14. sourceindex/cli/__init__.py +304 -0
  15. sourceindex/cli/__main__.py +10 -0
  16. sourceindex/cli/api_key.py +171 -0
  17. sourceindex/cli/commands.py +678 -0
  18. sourceindex/cli/install.py +378 -0
  19. sourceindex/daemon/__init__.py +83 -0
  20. sourceindex/daemon/client.py +141 -0
  21. sourceindex/daemon/crypto.py +48 -0
  22. sourceindex/daemon/keyring_store.py +129 -0
  23. sourceindex/daemon/lifecycle.py +237 -0
  24. sourceindex/daemon/protocol.py +134 -0
  25. sourceindex/daemon/server.py +389 -0
  26. sourceindex/daemon/store.py +426 -0
  27. sourceindex/lib/__init__.py +0 -0
  28. sourceindex/lib/backend.py +240 -0
  29. sourceindex/lib/cost.py +96 -0
  30. sourceindex/lib/env.py +30 -0
  31. sourceindex/lib/git.py +33 -0
  32. sourceindex/lib/languages.py +406 -0
  33. sourceindex/lib/llm.py +298 -0
  34. sourceindex/lib/log.py +228 -0
  35. sourceindex/lib/registry.py +75 -0
  36. sourceindex/lib/timing.py +10 -0
  37. sourceindex/search/__init__.py +251 -0
  38. sourceindex/search/experiments.py +276 -0
  39. sourceindex/search/imports.py +155 -0
  40. sourceindex/search/passes.py +51 -0
  41. sourceindex/search/prompts.py +379 -0
  42. sourceindex/search/roadmap.py +265 -0
  43. sourceindex/server.py +127 -0
  44. sourceindex-0.1.0.dist-info/METADATA +73 -0
  45. sourceindex-0.1.0.dist-info/RECORD +47 -0
  46. sourceindex-0.1.0.dist-info/WHEEL +4 -0
  47. sourceindex-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,403 @@
1
+ """LLM dispatch, response parsing, batch concurrency, and progress UI.
2
+
3
+ The indexer turns ``(content, relative_path, language)`` triples into
4
+ ``(oneliner, functions_text, usage)`` triples. Two delivery shapes:
5
+
6
+ - ``index_file`` — single-file, used by sequential update fallback.
7
+ - ``submit_and_wait_batch`` + ``index_by_hash`` — concurrent batch dispatch
8
+ via a ThreadPoolExecutor over ``call_llm``. Used for cold builds and
9
+ multi-file updates.
10
+
11
+ Progress UI (``_format_eta``, ``_render_progress_bar``, ``_emit_progress``)
12
+ and fd-capacity management (``_ensure_fd_capacity``) live here too —
13
+ they're only invoked from the batch path and don't deserve their own files.
14
+ """
15
+
16
+ import sys
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor, as_completed
19
+ from typing import Callable
20
+
21
+ import litellm
22
+
23
+ from .. import REALTIME_MAX_WORKERS
24
+ from ..lib.languages import Language
25
+ from ..lib.llm import LLMUsage, call_llm
26
+ from ..lib.log import get_logger
27
+ from .linerange import fix_functions_for_language
28
+ from .prompts import prompt_for
29
+
30
+ _log = get_logger(__name__)
31
+
32
+
33
+ REALTIME_MAX_RETRIES = 5
34
+ # update_index falls back to sequential index_file() below this miss count —
35
+ # the threadpool + per-request setup cost isn't worth amortizing for a single
36
+ # file, but pays for itself at 2+.
37
+ BATCH_MIN_FILES = 2
38
+ RETRY_DELAY = 1.0
39
+ # Best price/latency/quality balance found across DS-V4-Flash, Haiku 4.5,
40
+ # Mistral-Small, Gemini-Flash-Lite, gpt-5-nano, gpt-5.4-nano (Opus-judged
41
+ # description quality + AST-grounded recall + line-range accuracy).
42
+ # Reached via SOURCEINDEX_API_KEY: an sk-si bearer routes through the hosted
43
+ # backend, an sk-or-/sk-ant-/etc. provider key auto-opts-out and hits the
44
+ # provider directly. SOURCEINDEX_BACKEND_URL pins/disables the routing.
45
+ DEFAULT_MODEL = "openrouter/openai/gpt-5.4-nano"
46
+ BATCH_POLL_INTERVAL = 30
47
+ BATCH_MAX_WAIT = 3600
48
+ BATCH_MAX_TOKENS = 16384
49
+
50
+
51
+ # ── Response parsing ───────────────────────────────────────────────────────
52
+
53
+
54
+ def parse_response(response: str) -> tuple[str, str]:
55
+ """Parse merged LLM response into (oneliner, functions_text)."""
56
+ oneliner = ""
57
+ functions = ""
58
+
59
+ lines = response.split("\n")
60
+ for i, line in enumerate(lines):
61
+ if line.startswith("ONELINER:"):
62
+ oneliner = line[len("ONELINER:"):].strip()
63
+ elif line.startswith("FUNCTIONS:"):
64
+ functions = "\n".join(lines[i + 1:]).strip()
65
+ break
66
+
67
+ if not oneliner:
68
+ for line in lines:
69
+ s = line.strip()
70
+ if s and not s.startswith("#"):
71
+ oneliner = s[:80]
72
+ break
73
+
74
+ return oneliner, functions
75
+
76
+
77
+ # ── Single-file path (sequential fallback) ─────────────────────────────────
78
+
79
+
80
+ def index_file(
81
+ content: str,
82
+ relative_path: str,
83
+ language: Language,
84
+ model: str = DEFAULT_MODEL,
85
+ api_key: str | None = None,
86
+ ) -> tuple[str, str, LLMUsage]:
87
+ """Index a single file (tier-1 + tier-2 in one call when applicable).
88
+
89
+ Returns (oneliner, functions_text, usage). ``functions_text`` is always
90
+ empty when ``language.build_tier2`` is False. ``usage`` is zero-filled
91
+ when no LLM call succeeded (e.g. empty file, all retries failed).
92
+ """
93
+ if not content.strip():
94
+ return "(empty file)", "", LLMUsage()
95
+
96
+ prompt = prompt_for(language).format(
97
+ language_name=language.name,
98
+ relative_path=relative_path,
99
+ file_content=content,
100
+ )
101
+
102
+ total_usage = LLMUsage()
103
+ for attempt in range(2):
104
+ try:
105
+ response, usage = call_llm(prompt, model=model, api_key=api_key)
106
+ total_usage.add(usage)
107
+ if response:
108
+ oneliner, functions = parse_response(response)
109
+ functions = fix_functions_for_language(language, content, functions)
110
+ return oneliner, functions, total_usage
111
+ except litellm.AuthenticationError:
112
+ # Deterministic — retrying just delays the friendly CLI message.
113
+ raise
114
+ except Exception as e:
115
+ _log.warning(
116
+ f" LLM error on {relative_path}: {e}",
117
+ extra={
118
+ "event": "llm.retry",
119
+ "path": relative_path,
120
+ "attempt": attempt + 1,
121
+ "error_type": type(e).__name__,
122
+ "error": str(e),
123
+ },
124
+ )
125
+ if attempt == 0:
126
+ _log.info(
127
+ f" Retry in {RETRY_DELAY}s...",
128
+ extra={"event": "llm.retry_wait", "path": relative_path, "delay_s": RETRY_DELAY},
129
+ )
130
+ time.sleep(RETRY_DELAY)
131
+
132
+ _log.error(
133
+ f" LLM failed for {relative_path}, skipping",
134
+ extra={"event": "llm.failed", "path": relative_path},
135
+ )
136
+ return "(LLM summary failed)", "", total_usage
137
+
138
+
139
+ # ── Batch dispatch ─────────────────────────────────────────────────────────
140
+
141
+
142
+ def build_batch_requests(file_contents: dict[str, tuple[str, str, Language]], model: str = DEFAULT_MODEL) -> list[dict]:
143
+ """Build per-file LLM requests for concurrent dispatch.
144
+
145
+ file_contents: {content_hash: (file_content, relative_path, language)}
146
+ Uses the tier-1-only prompt for languages where build_tier2 is False.
147
+ """
148
+ requests = []
149
+ for h, (content, rel_path, language) in file_contents.items():
150
+ prompt = prompt_for(language).format(
151
+ language_name=language.name,
152
+ relative_path=rel_path,
153
+ file_content=content,
154
+ )
155
+ requests.append({
156
+ "custom_id": h,
157
+ "model": model,
158
+ "max_tokens": BATCH_MAX_TOKENS,
159
+ "prompt": prompt,
160
+ })
161
+ return requests
162
+
163
+
164
+ def _ensure_fd_capacity(needed: int) -> int:
165
+ """Raise this process's soft fd limit to fit ``needed`` open sockets,
166
+ plus a small headroom for stdio/log/cache fds. Returns the effective
167
+ worker capacity after the raise — may be less than ``needed`` if the
168
+ hard limit is lower (the caller must clamp). No-op on platforms
169
+ without a ``resource`` module (Windows)."""
170
+ try:
171
+ import resource
172
+ except ImportError:
173
+ return needed
174
+ HEADROOM = 64
175
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
176
+ target = needed + HEADROOM
177
+ if soft >= target:
178
+ return needed
179
+ new_soft = min(target, hard) if hard > 0 else target
180
+ try:
181
+ resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
182
+ except (ValueError, OSError):
183
+ return max(1, soft - HEADROOM)
184
+ return max(1, new_soft - HEADROOM)
185
+
186
+
187
+ # ── Progress UI ────────────────────────────────────────────────────────────
188
+
189
+
190
+ def _format_eta(seconds: float) -> str:
191
+ """Human-friendly elapsed/ETA: seconds under 90s, otherwise minutes."""
192
+ if seconds < 90:
193
+ return f"~{int(round(seconds))}s"
194
+ minutes = seconds / 60.0
195
+ if minutes < 10:
196
+ return f"~{minutes:.1f} min"
197
+ return f"~{int(round(minutes))} min"
198
+
199
+
200
+ def _render_progress_bar(done: int, total: int, elapsed: float, *, width: int = 28) -> str:
201
+ pct = (done / total) if total else 1.0
202
+ filled = int(pct * width)
203
+ bar = "█" * filled + "░" * (width - filled)
204
+ rate = done / elapsed if elapsed > 0 else 0.0
205
+ if rate > 0 and done < total:
206
+ eta = _format_eta((total - done) / rate)
207
+ else:
208
+ eta = "—"
209
+ return (
210
+ f"[{bar}] {int(pct * 100):3d}% "
211
+ f"elapsed {_format_eta(elapsed)} ETA {eta}"
212
+ )
213
+
214
+
215
+ _progress_writer: "Callable[[str], None] | None" = None
216
+
217
+
218
+ def set_progress_writer(fn: "Callable[[str], None] | None") -> None:
219
+ """Register a sink that receives every progress line. Used by the
220
+ daemon to forward progress to a file the CLI can tail; in-process
221
+ callers (build_index_with_store invoked directly) can leave it unset."""
222
+ global _progress_writer
223
+ _progress_writer = fn
224
+
225
+
226
+ def emit_status(line: str) -> None:
227
+ """One-shot status emit to the progress side channel. Use for phases
228
+ without per-item progress (e.g. post-batch finalization) so the CLI's
229
+ bar viewer doesn't appear frozen between the last LLM result and
230
+ 'Index written to …'."""
231
+ if _progress_writer is not None:
232
+ try:
233
+ _progress_writer(line)
234
+ except Exception:
235
+ pass
236
+
237
+
238
+ def _emit_progress(label: str, done: int, total: int, elapsed: float, *, is_tty: bool, final: bool = False) -> None:
239
+ line = f"[{label}] {_render_progress_bar(done, total, elapsed)}"
240
+ if _progress_writer is not None:
241
+ try:
242
+ _progress_writer(line)
243
+ except Exception:
244
+ pass
245
+ if is_tty and not final:
246
+ sys.stderr.write("\r\033[K" + line)
247
+ sys.stderr.flush()
248
+ else:
249
+ # Non-TTY: emit on milestones so logs aren't spammed but still show motion.
250
+ step = max(1, total // 20)
251
+ if final or done == total or done % step == 0:
252
+ print(line, file=sys.stderr)
253
+
254
+
255
+ def submit_and_wait_batch(
256
+ requests: list[dict],
257
+ api_key: str,
258
+ label: str = "batch",
259
+ *,
260
+ workers: int = REALTIME_MAX_WORKERS,
261
+ ) -> tuple[dict[str, str], LLMUsage]:
262
+ """Fan out requests concurrently through the LiteLLM adapter.
263
+
264
+ Returns ({custom_id: response_text}, aggregated_usage). Usage covers
265
+ every successful call across the pool; failed-after-retries calls drop
266
+ out (their attempts' usage is also lost — this matches the existing
267
+ "results dict" behaviour).
268
+ """
269
+ requested_pool = max(1, min(workers, len(requests)))
270
+ fd_cap = _ensure_fd_capacity(requested_pool)
271
+ pool_size = min(requested_pool, fd_cap)
272
+ if pool_size < requested_pool:
273
+ _log.warning(
274
+ f"[{label}] fd hard limit too low for {requested_pool} workers; "
275
+ f"clamping to {pool_size}. Raise `ulimit -n` to use more.",
276
+ extra={
277
+ "event": "batch.fd_clamp",
278
+ "label": label,
279
+ "requested": requested_pool,
280
+ "clamped_to": pool_size,
281
+ },
282
+ )
283
+ # Heuristic: ~5s/request amortized over the worker pool. The inline bar refines this once real rates are known.
284
+ est_seconds = (len(requests) / max(1, pool_size)) * 5.0
285
+ _log.info(
286
+ f"[{label}] Dispatching {len(requests)} requests "
287
+ f"(workers={pool_size}, est build time {_format_eta(est_seconds)})...",
288
+ extra={
289
+ "event": "batch.dispatch",
290
+ "label": label,
291
+ "requests": len(requests),
292
+ "workers": pool_size,
293
+ "est_seconds": round(est_seconds, 1),
294
+ },
295
+ )
296
+
297
+ def _one(req: dict) -> tuple[str, str | None, LLMUsage]:
298
+ cid = req["custom_id"]
299
+ last_err: Exception | None = None
300
+ attempt_usage = LLMUsage()
301
+ for attempt in range(REALTIME_MAX_RETRIES + 1):
302
+ try:
303
+ text, usage = call_llm(
304
+ req["prompt"],
305
+ model=req["model"],
306
+ api_key=api_key,
307
+ max_tokens=req["max_tokens"],
308
+ )
309
+ attempt_usage.add(usage)
310
+ return cid, text or "", attempt_usage
311
+ except litellm.AuthenticationError:
312
+ # Deterministic; let the outer loop cancel siblings.
313
+ raise
314
+ except Exception as e: # network/rate-limit/5xx
315
+ last_err = e
316
+ if attempt < REALTIME_MAX_RETRIES:
317
+ time.sleep(RETRY_DELAY * (attempt + 1))
318
+ _log.error(
319
+ f"[{label}] {cid}: failed after retries ({last_err})",
320
+ extra={
321
+ "event": "llm.failed",
322
+ "label": label,
323
+ "custom_id": cid,
324
+ "attempts": REALTIME_MAX_RETRIES + 1,
325
+ "error_type": type(last_err).__name__ if last_err else None,
326
+ "error": str(last_err) if last_err else None,
327
+ },
328
+ )
329
+ return cid, None, attempt_usage
330
+
331
+ results: dict[str, str] = {}
332
+ total_usage = LLMUsage()
333
+ succeeded = 0
334
+ failed = 0
335
+ total = len(requests)
336
+ start = time.time()
337
+ is_tty = sys.stderr.isatty()
338
+
339
+ with ThreadPoolExecutor(max_workers=pool_size) as pool:
340
+ # Materialize the futures list so we can cancel queued tasks on a
341
+ # fatal error. Default `with`-exit waits for everything to finish.
342
+ futures = [pool.submit(_one, r) for r in requests]
343
+ try:
344
+ for fut in as_completed(futures):
345
+ cid, text, usage = fut.result()
346
+ total_usage.add(usage)
347
+ if text is not None:
348
+ results[cid] = text
349
+ succeeded += 1
350
+ else:
351
+ failed += 1
352
+ done = succeeded + failed
353
+ _emit_progress(label, done, total, time.time() - start, is_tty=is_tty)
354
+ except litellm.AuthenticationError:
355
+ # In-flight threads can't be interrupted, but cancelling drains
356
+ # the queue so the remaining ~(total − pool_size) requests don't
357
+ # fire after we know the key is rejected.
358
+ for f in futures:
359
+ f.cancel()
360
+ raise
361
+
362
+ if is_tty:
363
+ sys.stderr.write("\n")
364
+ sys.stderr.flush()
365
+ _log.info(
366
+ f"[{label}] Results: {succeeded} succeeded, {failed} failed",
367
+ extra={
368
+ "event": "batch.results",
369
+ "label": label,
370
+ "succeeded": succeeded,
371
+ "failed": failed,
372
+ "elapsed_s": round(time.time() - start, 3),
373
+ "input_tokens": total_usage.input_tokens,
374
+ "output_tokens": total_usage.output_tokens,
375
+ "cost_usd": round(total_usage.cost_usd, 6),
376
+ },
377
+ )
378
+ return results, total_usage
379
+
380
+
381
+ def index_by_hash(
382
+ hash_to_content: dict[str, tuple[str, str, Language]],
383
+ *,
384
+ summary_cache: dict[str, dict[str, str]],
385
+ model: str,
386
+ api_key: str,
387
+ workers: int,
388
+ label: str,
389
+ ) -> tuple[dict[str, tuple[str, str]], LLMUsage]:
390
+ """Fan content-deduped requests out concurrently, parse + line-range-fix
391
+ responses, and merge into ``summary_cache``. Returns
392
+ ``(hash_to_parsed, usage)`` mapping each successful response's content
393
+ hash to its ``(oneliner, functions)`` tuple."""
394
+ requests = build_batch_requests(hash_to_content, model=model)
395
+ results, usage = submit_and_wait_batch(requests, api_key, label=label, workers=workers)
396
+ hash_to_parsed: dict[str, tuple[str, str]] = {}
397
+ for h, text in results.items():
398
+ oneliner, functions = parse_response(text)
399
+ content, _, lang = hash_to_content[h]
400
+ functions = fix_functions_for_language(lang, content, functions)
401
+ hash_to_parsed[h] = (oneliner, functions)
402
+ summary_cache[h] = {"oneliner": oneliner, "functions": functions}
403
+ return hash_to_parsed, usage
@@ -0,0 +1,24 @@
1
+ """Post-LLM line-range correction.
2
+
3
+ The build pipeline calls ``fix_functions_for_language`` after the LLM
4
+ returns its tier-2 entries. For Python we use the stdlib ``ast``; for
5
+ other languages we fall through to tree-sitter. Both fixers replace the
6
+ LLM's ``(Lstart-Lend)`` ranges with parser-truth ranges so the agent's
7
+ Read calls land on real function boundaries.
8
+ """
9
+
10
+ from ...lib.languages import Language
11
+ from .python_ast import fix_python_line_ranges, fill_missing_python_classes
12
+ from .treesitter import fix_line_ranges as _fix_treesitter_line_ranges
13
+
14
+
15
+ def fix_functions_for_language(lang: Language, content: str, functions: str) -> str:
16
+ """Apply language-specific line-range fixes to LLM-emitted functions text.
17
+ No-op when ``functions`` is empty."""
18
+ if not functions:
19
+ return functions
20
+ if lang.key == "python":
21
+ functions = fix_python_line_ranges(content, functions)
22
+ functions = fill_missing_python_classes(content, functions)
23
+ return functions
24
+ return _fix_treesitter_line_ranges(lang.key, content, functions)
@@ -0,0 +1,155 @@
1
+ """Python AST-grounded line-range fixer for tier-2 LLM output.
2
+
3
+ Cheaper LLMs (gemini, mistral, ds-v4-flash) emit roughly correct names but
4
+ fabricate line ranges. We replace LLM line numbers with AST ground truth
5
+ whenever the qualified name matches. Description quality is unchanged.
6
+ """
7
+
8
+ import ast
9
+ import re
10
+
11
+
12
+ _LINE_RANGE_RE = re.compile(r"\(L?\d+\s*-\s*L?\d+\)")
13
+ _ENTRY_RE = re.compile(r"^([\w.:_<>]+)\s+\(L?\d+\s*-\s*L?\d+\)\s*:?")
14
+
15
+
16
+ def _python_ast_truth(source: str) -> dict[str, tuple[int, int]]:
17
+ """{qualified_name: (start_lineno, end_lineno)} for every def in source.
18
+
19
+ Methods qualified as Class.method, nested funcs as Outer.inner AND
20
+ Outer.<locals>.inner so either naming convention matches."""
21
+ try:
22
+ tree = ast.parse(source)
23
+ except (SyntaxError, ValueError):
24
+ return {}
25
+
26
+ out: dict[str, tuple[int, int]] = {}
27
+
28
+ def walk(node, prefix: str = ""):
29
+ for child in ast.iter_child_nodes(node):
30
+ if isinstance(child, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
31
+ qname = f"{prefix}{child.name}" if prefix else child.name
32
+ out[qname] = (child.lineno, child.end_lineno or child.lineno)
33
+ walk(child, prefix=f"{qname}.")
34
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
35
+ walk(child, prefix=f"{qname}.<locals>.")
36
+
37
+ walk(tree)
38
+ return out
39
+
40
+
41
+ def fix_python_line_ranges(source: str, functions_text: str) -> str:
42
+ """Replace LLM-emitted line ranges with AST ground truth for Python files.
43
+
44
+ Lines whose qualified name matches an AST def get their (Lstart-Lend)
45
+ rewritten to the real range. Unmatched names (likely hallucinations or
46
+ deeply nested helpers AST didn't enumerate) are left untouched — kept
47
+ so a downstream judge can flag them, not silently dropped."""
48
+ truth = _python_ast_truth(source)
49
+ if not truth:
50
+ return functions_text
51
+
52
+ out_lines: list[str] = []
53
+ for line in functions_text.splitlines():
54
+ m = _ENTRY_RE.match(line.strip())
55
+ if not m:
56
+ out_lines.append(line)
57
+ continue
58
+ qname = m.group(1).rstrip(".")
59
+ match_key = qname if qname in truth else None
60
+ if match_key is None:
61
+ for k in truth:
62
+ if k.endswith(qname) or qname.endswith(k):
63
+ match_key = k
64
+ break
65
+ if match_key is None:
66
+ out_lines.append(line)
67
+ continue
68
+ ls, le = truth[match_key]
69
+ new_line = _LINE_RANGE_RE.sub(f"(L{ls}-L{le})", line, count=1)
70
+ if match_key != qname:
71
+ new_line = new_line.replace(qname, match_key, 1)
72
+ out_lines.append(new_line)
73
+ return "\n".join(out_lines)
74
+
75
+
76
+ def fill_missing_python_classes(source: str, functions_text: str) -> str:
77
+ """Defense-in-depth: inject class entries the LLM omitted but AST shows exist.
78
+
79
+ The current INDEX_PROMPT_TEMPLATE has class-specific examples and a
80
+ purpose-vs-behavior rubric, so gpt-5.4-nano + the in-tree prompt
81
+ produce full coverage on its own. This function is a no-op when the
82
+ LLM already lists every class. It's kept for two reasons: (a) cheaper
83
+ or differently-trained models may still drop class lines, (b) prompt
84
+ wording can drift in future commits and we don't want a regression to
85
+ silently lose class-level overview from the roadmap. Existing entries
86
+ are preserved verbatim — we only ADD lines, never modify."""
87
+ try:
88
+ tree = ast.parse(source)
89
+ except (SyntaxError, ValueError):
90
+ return functions_text
91
+
92
+ # Collect class defs with their method count + docstring oneliner.
93
+ class_defs: list[tuple[str, int, int, str]] = [] # (qname, ls, le, summary)
94
+
95
+ def walk_classes(node, prefix: str = ""):
96
+ for child in ast.iter_child_nodes(node):
97
+ if isinstance(child, ast.ClassDef):
98
+ qname = f"{prefix}{child.name}" if prefix else child.name
99
+ docstring = ast.get_docstring(child) or ""
100
+ first_line = (docstring.split("\n", 1)[0] or "").strip()
101
+ methods = sum(
102
+ 1 for c in child.body
103
+ if isinstance(c, (ast.FunctionDef, ast.AsyncFunctionDef))
104
+ )
105
+ if first_line:
106
+ summary = first_line[:90]
107
+ else:
108
+ bases = [
109
+ ast.unparse(b) if hasattr(ast, "unparse") else getattr(b, "id", "?")
110
+ for b in child.bases
111
+ ]
112
+ base_part = f"({', '.join(bases)})" if bases else ""
113
+ summary = f"Class {child.name}{base_part} with {methods} method(s)"
114
+ class_defs.append((
115
+ qname,
116
+ child.lineno,
117
+ child.end_lineno or child.lineno,
118
+ summary,
119
+ ))
120
+ walk_classes(child, prefix=f"{qname}.")
121
+ elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
122
+ walk_classes(child, prefix=f"{prefix}{child.name}.<locals>.")
123
+
124
+ walk_classes(tree)
125
+ if not class_defs:
126
+ return functions_text
127
+
128
+ # Find which class names already appear in the LLM output.
129
+ present = set()
130
+ for line in functions_text.splitlines():
131
+ m = _ENTRY_RE.match(line.strip())
132
+ if m:
133
+ present.add(m.group(1).rstrip("."))
134
+
135
+ # Inject missing classes immediately before the first method that
136
+ # belongs to them (so file order is preserved). Classes with no method
137
+ # yet listed get appended at the end.
138
+ out_lines = list(functions_text.splitlines())
139
+ for qname, ls, le, summary in class_defs:
140
+ if qname in present:
141
+ continue
142
+ new_entry = f"{qname} (L{ls}-L{le}): {summary}"
143
+ # Find first line whose qname starts with `qname.`
144
+ method_prefix = f"{qname}."
145
+ injected = False
146
+ for i, line in enumerate(out_lines):
147
+ m = _ENTRY_RE.match(line.strip())
148
+ if m and m.group(1).startswith(method_prefix):
149
+ out_lines.insert(i, new_entry)
150
+ injected = True
151
+ break
152
+ if not injected:
153
+ out_lines.append(new_entry)
154
+
155
+ return "\n".join(out_lines)