codebase-explorer 2.2.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. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/generate.py ADDED
@@ -0,0 +1,316 @@
1
+ """Script-driven, single-turn codebase documentation generation.
2
+
3
+ The controller is ordinary Python: there is no model parent session and no
4
+ per-task native handoff. Every host completion is a fresh, tool-free session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import hashlib
11
+ import json
12
+ import time
13
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from cbe import batching, usage
18
+ from cbe.api_completion import FIELDS, CSV_LOCK, invoke
19
+ from cbe.model_settings import DEFAULT_API_CONFIG, load_config
20
+
21
+
22
+ class GenerateError(ValueError):
23
+ pass
24
+
25
+
26
+ class SourceNotEmbedded(GenerateError):
27
+ """A script-driver prompt lacks a source body it must embed before any model call.
28
+
29
+ The v2.1.0 failure mode: modules were rebuilt from run state that keeps
30
+ digests, not text, so every prompt named the files but carried empty bodies.
31
+ """
32
+
33
+
34
+ # Module and repair detail tasks allow 3 answers; other tasks allow 2 (see
35
+ # host_run). The provider's own pushback (HTTP 429/502/503/504) is waited out inside the
36
+ # call and never uses an answer attempt. A task whose call still ends in
37
+ # ``provider_limit`` goes back to the queue and the whole run pauses; the run
38
+ # stops only after this many such calls in a row with no response in between.
39
+ LIMIT_STREAK_STOP = 3
40
+ LIMIT_PAUSE_SECONDS = 60
41
+ _sleep = time.sleep
42
+
43
+
44
+ def _mark_result(csv_path: Path, task: str, attempt: int, result: str) -> None:
45
+ # This runs after invoke has appended its row. Other workers may append
46
+ # rows meanwhile, so rewrite under the same lock invoke uses.
47
+ with CSV_LOCK:
48
+ with csv_path.open(newline="", encoding="utf-8") as stream:
49
+ rows = list(csv.DictReader(stream))
50
+ for row in reversed(rows):
51
+ if row["task"] == task and row["attempt"] == str(attempt):
52
+ row["result"] = result
53
+ break
54
+ with csv_path.open("w", newline="", encoding="utf-8") as stream:
55
+ writer = csv.DictWriter(stream, fieldnames=FIELDS)
56
+ writer.writeheader()
57
+ writer.writerows(rows)
58
+
59
+
60
+ # A script completion has no file tools, so the model must have been sent the source text. If the
61
+ # model reports far fewer input tokens than the source it was sent, it never saw the code.
62
+ DELIVERY_FLOOR = 0.5
63
+
64
+
65
+ def _delivery_problem(run_dir: Path) -> tuple[str | None, str]:
66
+ """Report delivery failure only when a source-bearing call proved it.
67
+
68
+ Failed calls have no reported usage. Comparing their missing counts to all
69
+ source sent confuses timeout/provider failures with missing source.
70
+ """
71
+ state = json.loads((run_dir / "host" / "state.json").read_text(encoding="utf-8"))
72
+ failures = [(task_id, task["errors"][-1]) for task_id, task in state["tasks"].items()
73
+ if task["status"] == "failed" and task["errors"]
74
+ and task["errors"][-1].startswith("source_not_delivered")]
75
+ if failures:
76
+ return "source_not_delivered", "; ".join(f"{task}: {error}" for task, error in failures)
77
+ return None, ""
78
+
79
+
80
+ # Tasks whose prompt embeds source bodies; tree naming and system prompts embed none.
81
+ SOURCE_TASK_KINDS = ("module", "repair", "review")
82
+
83
+
84
+ def _embedded_paths(state: dict[str, Any], task_id: str) -> list[str]:
85
+ """Module file paths a task's prompt must embed; empty when it embeds none."""
86
+ task = state["tasks"][task_id]
87
+ if task["kind"] not in SOURCE_TASK_KINDS:
88
+ return []
89
+ return list(state["modules"][task["module"]]["files"])
90
+
91
+
92
+ def _verify_embedded_source(run_dir: Path, task_id: str, prompt: str) -> None:
93
+ """Pre-flight before each script-driver model call: every file the task's
94
+ prompt must embed is present in the prompt with its full body, read back
95
+ from disk and checked against the plan digest.
96
+
97
+ Under the semantic mainline the prompt embeds the frozen context chunks
98
+ rather than whole files, so the check is that each of the task's chunks is
99
+ present verbatim in the prompt. Raises :class:`SourceNotEmbedded` naming the
100
+ file, so the run stops before the call is spent."""
101
+ state = json.loads((run_dir / "host" / "state.json").read_text(encoding="utf-8"))
102
+ paths = _embedded_paths(state, task_id)
103
+ if not paths:
104
+ return
105
+ planned = {row["path"]: row for row in state["files"]}
106
+ repo = Path(state["repo"])
107
+ for path in paths:
108
+ row = planned[path]
109
+ try:
110
+ raw = (repo / path).read_bytes()
111
+ except OSError as exc:
112
+ raise SourceNotEmbedded(f"{path}: cannot be read ({exc.strerror or exc})") from exc
113
+ if hashlib.sha256(raw).hexdigest() != row["digest"]:
114
+ raise SourceNotEmbedded(f"{path}: changed since the plan (digest mismatch)")
115
+ body = raw.decode("utf-8")
116
+ if not body and not row["tokens"]:
117
+ continue # an empty file embeds nothing by design
118
+ if body not in prompt:
119
+ raise SourceNotEmbedded(
120
+ f"{path}: not embedded in the prompt ({row['tokens']} planned tokens); the model was not called")
121
+
122
+
123
+ def _call_delivery_problem(run_dir: Path, task_id: str,
124
+ usage: dict[str, int | None] | None) -> str | None:
125
+ """Per-call delivery check: reject an answer unless the model reported at
126
+ least the delivery floor of the source tokens embedded in its own prompt.
127
+
128
+ Returns None when the call is fine or nothing was reported (the caller
129
+ records ``usage_unknown`` for silent hosts instead of failing)."""
130
+ if not usage:
131
+ return None
132
+ state = json.loads((run_dir / "host" / "state.json").read_text(encoding="utf-8"))
133
+ paths = _embedded_paths(state, task_id)
134
+ if not paths:
135
+ return None
136
+ planned = {row["path"]: row["tokens"] for row in state["files"]}
137
+ sent = sum(planned[path] for path in paths)
138
+ reported = (usage.get("input") or 0) + (usage.get("cache") or 0)
139
+ if sent <= 0 or reported >= DELIVERY_FLOOR * sent:
140
+ return None
141
+ return (f"source_not_delivered: reported {reported} input tokens (input + cache-read) "
142
+ f"against the {sent} source tokens embedded in this prompt")
143
+
144
+
145
+ def _csv_task(task_id: str, review: str) -> str:
146
+ """Keep calls.csv task names comparable with earlier script runs."""
147
+ if task_id == "system":
148
+ return "system_overview"
149
+ if review == "sample" and task_id.startswith(("review_", "repair_")):
150
+ return "sample_review" if task_id.startswith("review_") else "sample_repair"
151
+ return task_id
152
+
153
+
154
+ def generate(repo: Path, run_dir: Path, *, model: str, config: Path | None = None,
155
+ jobs: int = 4, review: str | None = None, timeout: int | None = None,
156
+ resume: bool = False,
157
+ graph_path: Path | None = None, api_base_url: str | None = None,
158
+ api_key_env: str | None = None, default_timeout: int = 360) -> dict[str, Any]:
159
+ """Script driver: the shared run state, with each task as one tool-free completion.
160
+
161
+ A task failure never voids the run. Finished modules render; failed parts
162
+ stay pending in the run state, and ``resume=True`` continues the same run.
163
+
164
+ Two hard delivery checks guard every call: a pre-flight that each prompt
165
+ really embeds its module's source (``SourceNotEmbedded`` stops the run
166
+ before the call is spent), and a per-call token check that rejects answers
167
+ whose reported input is under half the embedded source.
168
+ """
169
+ from cbe import host_run
170
+
171
+ review = review or host_run.DEFAULT_REVIEW
172
+ repo, run_dir = repo.resolve(), run_dir.resolve()
173
+ if not repo.is_dir():
174
+ raise GenerateError("repository root is not a directory")
175
+ if jobs < 1 or default_timeout < 1 or review not in host_run.REVIEW_MODES or (timeout is not None and timeout < 1):
176
+ raise GenerateError("jobs and timeout must be positive; review must be none, sample, or all")
177
+ if not model:
178
+ raise GenerateError("generate needs a model name (--model)")
179
+ has_state = (run_dir / "host" / "state.json").is_file()
180
+ if resume and not has_state:
181
+ raise GenerateError("nothing to resume: the run directory has no run state")
182
+ if not resume and ((run_dir / "calls.csv").exists() or has_state):
183
+ raise GenerateError("run directory already contains a run; pass --resume or choose a fresh run directory")
184
+ run_dir.mkdir(parents=True, exist_ok=True)
185
+ if config is None:
186
+ config = run_dir / "api-completion.json"
187
+ if not config.is_file():
188
+ config.write_text(json.dumps(DEFAULT_API_CONFIG, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
189
+ else:
190
+ config = config.resolve()
191
+ if not config.is_file():
192
+ raise GenerateError("config file does not exist")
193
+ api_overrides: dict[str, Any] = {}
194
+ settings = load_config(config, model)
195
+ # Keep the historical Python default (360) and CLI default (420), while
196
+ # distinguishing an explicit --timeout from a model/config default.
197
+ timeout = timeout if timeout is not None else settings.get("timeout", default_timeout)
198
+ # Apply the CLI overrides in memory only: never copy a user config that
199
+ # may carry an auth header into the run directory.
200
+ if api_base_url:
201
+ api_overrides["base_url"] = api_base_url
202
+ if api_key_env:
203
+ api_overrides["api_key_env"] = api_key_env
204
+ previous_wall = 0.0
205
+ if resume and (run_dir / "summary.json").is_file():
206
+ previous_wall = json.loads((run_dir / "summary.json").read_text(encoding="utf-8")).get("wall_seconds", 0)
207
+ started = time.monotonic()
208
+ if resume:
209
+ host_run.resume(run_dir, owner="api", graph_path=graph_path)
210
+ else:
211
+ host_run.plan(repo, run_dir, review=review, jobs=jobs, host="api", driver="script",
212
+ graph_path=graph_path,
213
+ batch_target_tokens=settings.get("batch_target_tokens", batching.TARGET_TOKENS),
214
+ batch_max_tokens=settings.get("batch_max_tokens", batching.MAX_TOKENS))
215
+ review = json.loads((run_dir / "host" / "state.json").read_text(encoding="utf-8"))["review"]
216
+ csv_path = run_dir / "calls.csv"
217
+ stopped: str | None = None
218
+ limit_streak, pause_until = 0, 0.0
219
+ active: dict[Any, tuple[dict[str, Any], str]] = {}
220
+ preflight: SourceNotEmbedded | None = None
221
+ with ThreadPoolExecutor(max_workers=jobs) as pool:
222
+ while True:
223
+ paused = bool(pause_until) and time.monotonic() < pause_until
224
+ if stopped is None and preflight is None and len(active) < jobs and not paused:
225
+ try:
226
+ batch = host_run.next_tasks(run_dir, limit=jobs - len(active), owner="api")["tasks"]
227
+ except host_run.HostRunError as exc:
228
+ # Dispatch itself refused the task (a listed source is
229
+ # missing, unreadable, or drifted); stop the same way.
230
+ preflight = SourceNotEmbedded(str(exc))
231
+ host_run.mark_partial(run_dir, "source_not_embedded", str(preflight))
232
+ batch = []
233
+ for item in batch:
234
+ csv_task = _csv_task(item["task"], review)
235
+ try:
236
+ prompt = host_run.completion_prompt(run_dir, item["task"])
237
+ _verify_embedded_source(run_dir, item["task"], prompt)
238
+ except (SourceNotEmbedded, host_run.HostRunError, OSError) as exc:
239
+ # The prompt cannot carry this module's source (unreadable,
240
+ # drifted, or bodies missing), so the model must not be
241
+ # called; stop the run instead of paying for documentation
242
+ # written from file names.
243
+ preflight = exc if isinstance(exc, SourceNotEmbedded) else SourceNotEmbedded(str(exc))
244
+ host_run.mark_partial(run_dir, "source_not_embedded", str(preflight))
245
+ break
246
+ future = pool.submit(invoke, task=csv_task, attempt=item["attempt"],
247
+ prompt=prompt,
248
+ run_dir=run_dir, model=model, config=config,
249
+ timeout=timeout, csv_path=csv_path,
250
+ overrides=api_overrides or None)
251
+ active[future] = (item, csv_task)
252
+ if not active:
253
+ if stopped is None and preflight is None and paused:
254
+ _sleep(max(pause_until - time.monotonic(), 0.0))
255
+ continue
256
+ break
257
+ done, _ = wait(active, return_when=FIRST_COMPLETED)
258
+ for future in done:
259
+ item, csv_task = active.pop(future)
260
+ try:
261
+ completion = future.result()
262
+ except Exception as exc: # noqa: BLE001 - one broken call must not void the run
263
+ outcome = host_run.submit(run_dir, item["task"], error=f"driver_error:{exc}")
264
+ continue
265
+ if completion.error == "provider_limit":
266
+ # Not an answer: back to the queue without using an attempt.
267
+ host_run.release(run_dir, item["task"])
268
+ limit_streak += 1
269
+ if limit_streak >= LIMIT_STREAK_STOP:
270
+ stopped = "provider_limit"
271
+ else:
272
+ pause_until = max(pause_until, time.monotonic() + LIMIT_PAUSE_SECONDS)
273
+ continue
274
+ limit_streak = 0
275
+ if completion.error:
276
+ outcome = host_run.submit(run_dir, item["task"], error=completion.error,
277
+ raw_text=completion.raw_text or None)
278
+ if completion.error == "provider_auth":
279
+ stopped = completion.error
280
+ continue
281
+ delivery = _call_delivery_problem(run_dir, item["task"], completion.usage)
282
+ if delivery is not None:
283
+ # The model never saw this prompt's source, so its answer
284
+ # is not accepted; the retry/partial logic takes over.
285
+ outcome = host_run.submit(run_dir, item["task"], error=delivery,
286
+ raw_text=completion.raw_text or None)
287
+ _mark_result(csv_path, csv_task, item["attempt"], "source_not_delivered")
288
+ continue
289
+ if completion.usage is None:
290
+ host_run.note_usage_unknown(run_dir, item["task"], item["attempt"])
291
+ Path(item["output_file"]).write_text(completion.raw_text, encoding="utf-8")
292
+ outcome = host_run.submit(run_dir, item["task"])
293
+ if not outcome["accepted"] and outcome["error"].startswith(("schema_error", "incomplete")):
294
+ # The host call completed; its CSV row must still show the check failure.
295
+ _mark_result(csv_path, csv_task, item["attempt"], outcome["error"].split(":", 1)[0])
296
+ elif outcome.get("normalized"):
297
+ _mark_result(csv_path, csv_task, item["attempt"], "normalized")
298
+ if stopped or preflight is not None:
299
+ host_run.release(run_dir)
300
+ cost = (usage.script_cost(run_dir, price=settings.get("price_per_million"))
301
+ if csv_path.is_file() else {"calls": 0})
302
+ if preflight is None:
303
+ host_run.mark_partial(run_dir, *_delivery_problem(run_dir))
304
+ result = host_run.finish(run_dir)
305
+ summary = {**result, "status": result.get("status", "partial"),
306
+ "stopped": stopped, "wall_seconds": round(previous_wall + time.monotonic() - started, 3),
307
+ "run_dir": str(run_dir), "index": str(run_dir / "docs" / "INDEX.md"),
308
+ "progress_log": str(run_dir / "progress.log"), "host": "api", "model": model,
309
+ "input_token_basis": "provider prompt_tokens minus cached_tokens; cache reported separately",
310
+ **cost}
311
+ (run_dir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
312
+ if preflight is not None:
313
+ raise preflight
314
+ return summary
315
+
316
+