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/model_settings.py ADDED
@@ -0,0 +1,80 @@
1
+ """API model resource defaults and user configuration, in one place.
2
+
3
+ Exact model ids opt in; unknown models retain the historical API behavior.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from cbe import batching
13
+
14
+
15
+ DEFAULT_API_CONFIG: dict[str, Any] = {
16
+ "base_url": "https://api.openai.com/v1",
17
+ "api_key_env": "OPENAI_API_KEY",
18
+ "api_key_file": None,
19
+ "extra_headers": {},
20
+ }
21
+ #: Provider request fields sent only when configured; ``api_completion`` reads exactly these.
22
+ OPTIONAL_REQUEST_KEYS = ("response_format", "top_p", "reasoning_effort")
23
+ #: Every config key the code reads. ``load_config`` rejects anything else.
24
+ ALLOWED_API_CONFIG_KEYS = frozenset(DEFAULT_API_CONFIG) | frozenset(OPTIONAL_REQUEST_KEYS) | {
25
+ "timeout", "output_max_tokens", "batch_target_tokens", "batch_max_tokens",
26
+ "temperature", "price_per_million",
27
+ }
28
+ MODEL_DEFAULTS: dict[str, dict[str, Any]] = {
29
+ "zai/glm-5.3-flash": {
30
+ "timeout": 1800,
31
+ "output_max_tokens": 49152,
32
+ "reasoning_effort": "low",
33
+ "response_format": {"type": "json_object"},
34
+ "batch_target_tokens": 8000,
35
+ "batch_max_tokens": 10000,
36
+ },
37
+ }
38
+
39
+
40
+ class ApiConfigError(ValueError):
41
+ pass
42
+
43
+
44
+ def model_defaults(model: str) -> dict[str, Any]:
45
+ """Defaults for a model: the exact name, else the same model under another gateway prefix."""
46
+ if model in MODEL_DEFAULTS:
47
+ return MODEL_DEFAULTS[model]
48
+ name = model.rsplit("/", 1)[-1]
49
+ return next((row for key, row in MODEL_DEFAULTS.items() if key.rsplit("/", 1)[-1] == name), {})
50
+
51
+
52
+ def load_config(path: Path, model: str = "") -> dict[str, Any]:
53
+ """Merge transport defaults < model defaults < user JSON settings."""
54
+ value = {**DEFAULT_API_CONFIG, **model_defaults(model)}
55
+ if path.is_file():
56
+ try:
57
+ loaded = json.loads(path.read_text(encoding="utf-8"))
58
+ except (OSError, json.JSONDecodeError) as exc:
59
+ raise ApiConfigError(f"API config is not valid JSON: {exc}") from exc
60
+ if not isinstance(loaded, dict):
61
+ raise ApiConfigError("API config must be a JSON object")
62
+ unknown = sorted(set(loaded) - ALLOWED_API_CONFIG_KEYS)
63
+ if unknown:
64
+ raise ApiConfigError(f"unknown API config keys: {', '.join(unknown)}")
65
+ value.update(loaded)
66
+ for key in ("timeout", "output_max_tokens", "batch_target_tokens", "batch_max_tokens"):
67
+ if key in value and (type(value[key]) is not int or value[key] < 1):
68
+ raise ApiConfigError(f"{key} must be a positive integer")
69
+ batch_max = value.get("batch_max_tokens", batching.MAX_TOKENS)
70
+ target = value.get("batch_target_tokens", min(batching.TARGET_TOKENS, batch_max))
71
+ if target > batch_max:
72
+ raise ApiConfigError("batch_target_tokens must not exceed batch_max_tokens")
73
+ if "batch_max_tokens" in value:
74
+ value.setdefault("batch_target_tokens", target)
75
+ rates = value.get("price_per_million")
76
+ if rates is not None:
77
+ if (not isinstance(rates, list) or len(rates) != 3 or
78
+ any(type(rate) not in (int, float) or not math.isfinite(rate) or rate < 0 for rate in rates)):
79
+ raise ApiConfigError("price_per_million needs three finite nonnegative rates: input,output,cache")
80
+ return value
cbe/projection.py ADDED
@@ -0,0 +1,305 @@
1
+ """Unit-level graph evidence for detail authors, shared by both drivers.
2
+
3
+ The inventory defines identity; chunks define call-site ownership. Rendering and
4
+ budget selection live here so host files and all detail/review/repair prompts
5
+ cannot acquire different projection rules.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter, defaultdict
10
+ from dataclasses import dataclass
11
+ import re
12
+ from typing import Any
13
+
14
+ from cbe.records import SourceOwnership
15
+ from cbe.token_budget import count_text_tokens
16
+
17
+ DEFAULT_GRAPH_MAX_TOKENS = 3000
18
+
19
+ # Joern names observed in the O1c Flask and O1b cjson/polyglot graphs;
20
+ # C3b's independent cfg_check.py also documents <clinit>. Match only known
21
+ # terminal suffixes: <redefined>, <duplicate>, <const> and <init> are real
22
+ # source methods, and a parent <lambda> must not hide a named inner function.
23
+ _SYNTHETIC_CFG_OWNER = re.compile(
24
+ r"<(?:lambda|metaClassAdapter|metaClassCallHandler|fakeNew|module|body|"
25
+ r"main|global|clinit)>\d*$"
26
+ )
27
+
28
+
29
+ def graph_state(graph: Any) -> tuple[str, str]:
30
+ """Use the graph module's authoritative availability classification."""
31
+ from cbe.graph import graph_state as classify_graph
32
+
33
+ return classify_graph(graph)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class EvidenceRow:
38
+ category: str
39
+ text: str
40
+ count: int = 1
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class UnitGraph:
45
+ """Unbounded rows in priority order, plus non-optional mapping accounting."""
46
+ rows: tuple[EvidenceRow, ...]
47
+ internal_calls: int
48
+ unmapped_calls: int
49
+ unmapped_cfg: int = 0
50
+ exception_exits_exported: bool = False
51
+
52
+
53
+ def unit_graph(graph: dict, paths: list[str], inventory: dict[str, dict],
54
+ chunks: list[dict]) -> UnitGraph:
55
+ """Map call coordinates into inventory chunks' half-open byte ranges.
56
+
57
+ Frozen chunk text reconstructs source coordinates, including UTF-8 columns.
58
+ Missing coordinates or source bytes are counted rather than guessed by line.
59
+ """
60
+ wanted = set(paths)
61
+ nodes = {n["id"]: n for n in graph.get("nodes", [])}
62
+ ids = {s["id"] for entry in inventory.values() for s in entry["symbols"]}
63
+ parts: dict[str, list[dict]] = defaultdict(list)
64
+ for chunk in chunks:
65
+ parts[chunk["path"]].append(chunk)
66
+ sources = {path: b"".join(c.get("text", "").encode("utf-8")
67
+ for c in sorted(rows, key=lambda c: c.get("start_byte", 0))).decode("utf-8")
68
+ for path, rows in parts.items()}
69
+ at = SourceOwnership(sources, chunks, ids).at
70
+
71
+ def endpoint(node: dict) -> str | None:
72
+ return at(node.get("path"), node.get("start_line"))
73
+
74
+ outgoing, incoming, ambiguous = [], [], []
75
+ dependencies: dict[str, set[str]] = defaultdict(set)
76
+ external_calls: Counter = Counter()
77
+ unresolved: dict[str, list[str]] = defaultdict(list)
78
+ skeleton: Counter = Counter()
79
+ internal = unmapped = 0
80
+ for edge in sorted(graph.get("edges", []), key=lambda e: str(e.get("id", ""))):
81
+ source = nodes.get(edge.get("source"), {})
82
+ target = nodes.get(edge.get("target"), {})
83
+ path = edge.get("path") or source.get("path")
84
+ target_path = target.get("path")
85
+ local_source, local_target = path in wanted, target_path in wanted
86
+ if edge.get("kind") == "import":
87
+ if edge.get("status") == "external" and local_source:
88
+ dependencies[path].add(str(target.get("name") or edge.get("target_expression")
89
+ or edge.get("expression") or "unknown"))
90
+ if edge.get("status") == "resolved" and path and target_path:
91
+ left = path.rpartition("/")[0] or "."
92
+ right = target_path.rpartition("/")[0] or "."
93
+ skeleton[left, right] += 1
94
+ continue
95
+ if edge.get("kind") != "call" or not (local_source or local_target):
96
+ continue
97
+ caller = at(path, edge.get("line"), edge.get("column"))
98
+ callee = endpoint(target)
99
+ status = edge.get("status")
100
+ if status == "resolved":
101
+ if not caller or not callee:
102
+ unmapped += 1
103
+ continue
104
+ if local_source and local_target:
105
+ internal += 1
106
+ elif local_source:
107
+ outgoing.append((caller, callee, target_path, target.get("start_line")))
108
+ else:
109
+ incoming.append((callee, caller, path, edge.get("line")))
110
+ elif status == "ambiguous":
111
+ candidates = []
112
+ for candidate in (edge.get("candidates") or [])[:3]:
113
+ node = nodes.get(candidate, {}) if isinstance(candidate, str) else candidate
114
+ candidates.append(endpoint(node) or str(node.get("name") or candidate))
115
+ ambiguous.append(EvidenceRow("ambiguous", f"? {caller or path}: "
116
+ f"{edge.get('expression') or edge.get('target_expression') or '?'} => "
117
+ + "; ".join(candidates) + f" ({len(edge.get('candidates') or [])} candidates)"))
118
+ elif status == "external" and local_source:
119
+ external_calls[path] += 1
120
+ elif status == "unresolved" and local_source:
121
+ expression = str(edge.get("target_expression") or edge.get("expression") or "?")
122
+ unresolved[path].append(expression.split("(", 1)[0].strip()[:80])
123
+
124
+ rows: list[EvidenceRow] = []
125
+ # Group by caller/callee without duplicating an inventory id for every site.
126
+ # Repeated sites remain counted even if their endpoint pair is the same.
127
+ for category, arrow, relations in (("out", "→", outgoing), ("in", "←", incoming)):
128
+ grouped: dict[tuple, Counter] = defaultdict(Counter)
129
+ for local, remote, remote_path, line in relations:
130
+ grouped[local][remote, remote_path, line] += 1
131
+ for local, destinations in sorted(grouped.items()):
132
+ for (remote, remote_path, line), count in sorted(destinations.items()):
133
+ # Each row is independently truncatable; repeated local ids are
134
+ # compressed by render_rows below, keeping omission counts exact.
135
+ rows.append(EvidenceRow(category, f"{local} {arrow} {remote} ({remote_path}:{line})"
136
+ + (f" ×{count}" if count > 1 else ""), count))
137
+ rows += sorted(ambiguous, key=lambda r: r.text)
138
+ for path in sorted(wanted):
139
+ names = ", ".join(sorted(dependencies[path])) or "none"
140
+ rows.append(EvidenceRow("dependencies", f"External imports {path}: {names}; "
141
+ f"external calls={external_calls[path]}",
142
+ len(dependencies[path]) + external_calls[path]))
143
+ if unresolved[path]:
144
+ names = list(dict.fromkeys(unresolved[path]))[:5]
145
+ rows.append(EvidenceRow("unresolved", f"Unresolved calls {path}: {len(unresolved[path])}; "
146
+ + "; ".join(names), len(unresolved[path])))
147
+
148
+ symbol_kinds = {s["id"]: s.get("kind") for entry in inventory.values() for s in entry["symbols"]}
149
+ cfg_nodes: dict[str, list[dict]] = defaultdict(list)
150
+ unmapped_owners = set()
151
+ for node in nodes.values():
152
+ if node.get("kind") == "cfg_node" and node.get("path") in wanted:
153
+ owner = nodes.get(node.get("owner"), {})
154
+ method_name = str(owner.get("name", "")).rsplit(".", 1)[-1]
155
+ if _SYNTHETIC_CFG_OWNER.search(method_name):
156
+ continue
157
+ uid = endpoint(owner)
158
+ if uid and symbol_kinds.get(uid) in {"function", "method"}:
159
+ cfg_nodes[uid].append(node)
160
+ else:
161
+ unmapped_owners.add(node.get("owner"))
162
+ for uid, members in sorted(cfg_nodes.items()):
163
+ controls = sum(n.get("label") == "CONTROL_STRUCTURE" for n in members)
164
+ returns = sum(n.get("label") == "RETURN" for n in members)
165
+ if controls or returns:
166
+ rows.append(EvidenceRow("cfg", f"CFG {uid}: control_structures={controls}, "
167
+ f"returns={returns}"))
168
+ # A package skeleton is deliberately small; preserve its omitted pair count.
169
+ for (left, right), count in sorted(skeleton.items())[:12]:
170
+ rows.append(EvidenceRow("packages", f"Package imports {left} → {right}: {count}"))
171
+ if len(skeleton) > 12:
172
+ rows.append(EvidenceRow("packages", f"Package imports: {len(skeleton) - 12} other package pairs",
173
+ len(skeleton) - 12))
174
+ if not skeleton:
175
+ rows.append(EvidenceRow("packages", "Package imports: no resolved package links", 0))
176
+ # Package context survives before lower-priority per-function cfg hints.
177
+ rows.sort(key=lambda row: {"out": 0, "in": 1, "ambiguous": 2,
178
+ "dependencies": 3, "unresolved": 3,
179
+ "packages": 4, "cfg": 5}[row.category])
180
+ return UnitGraph(tuple(rows), internal, unmapped, len(unmapped_owners),
181
+ any(n.get("label") == "THROW" for n in nodes.values()))
182
+
183
+
184
+ def render_rows(rows: list[EvidenceRow]) -> list[str]:
185
+ """Group adjacent relations by local inventory id, without new identities."""
186
+ lines: list[str] = []
187
+ previous = None
188
+ for row in rows:
189
+ if row.category in {"out", "in"}:
190
+ arrow = " → " if row.category == "out" else " ← "
191
+ local, remote = row.text.split(arrow, 1)
192
+ key = row.category, local
193
+ if previous == key:
194
+ lines.append(" " + arrow.strip() + " " + remote)
195
+ else:
196
+ lines.append(row.text)
197
+ previous = key
198
+ else:
199
+ lines.append(row.text)
200
+ previous = None
201
+ return lines
202
+
203
+
204
+ def graph_text(evidence: UnitGraph, *, budget: int | None = None) -> str:
205
+ """Drop a suffix in reverse priority, accounting for every omitted relation.
206
+
207
+ The notice itself participates in token accounting. Full host files use
208
+ budget=None and the exact same rows. No dataflow nodes are ever rendered.
209
+ """
210
+ rows = list(evidence.rows)
211
+ suffix: list[Counter] = [Counter() for _ in range(len(rows) + 1)]
212
+ for index in range(len(rows) - 1, -1, -1):
213
+ suffix[index] = suffix[index + 1].copy()
214
+ if rows[index].count:
215
+ suffix[index][rows[index].category] += rows[index].count
216
+
217
+ def render(keep: int) -> str:
218
+ omitted = suffix[keep]
219
+ lines = ["LOCAL GRAPH EVIDENCE (unit ids; dependencies count import names + external call sites; graph evidence is not proof of behavior).",
220
+ f"Batch-internal resolved calls: {evidence.internal_calls} (source visible); "
221
+ f"unmapped resolved calls: {evidence.unmapped_calls}; "
222
+ f"unmapped cfg owners: {evidence.unmapped_cfg}."
223
+ + ("" if evidence.exception_exits_exported else
224
+ " exception exits are not exported by this graph.")]
225
+ lines += render_rows(rows[:keep])
226
+ lines.append("Omitted: " + (", ".join(f"{k}={v}" for k, v in sorted(omitted.items())) or "none")
227
+ + ". Omitted evidence is unavailable in this excerpt.")
228
+ return "\n".join(lines)
229
+
230
+ text = render(len(rows))
231
+ if budget is None or count_text_tokens(text) <= budget:
232
+ return text
233
+ low, high = 0, len(rows) - 1
234
+ best = ""
235
+ while low <= high:
236
+ middle = (low + high) // 2
237
+ candidate = render(middle)
238
+ if count_text_tokens(candidate) <= budget:
239
+ best = candidate
240
+ low = middle + 1
241
+ else:
242
+ high = middle - 1
243
+ if best:
244
+ return best
245
+ text = (f"Graph: internal={evidence.internal_calls}, unmapped={evidence.unmapped_calls}, "
246
+ f"unmapped_cfg={evidence.unmapped_cfg}; omitted "
247
+ + ",".join(f"{k}={v}" for k, v in sorted(suffix[0].items()))
248
+ + "; dependencies unit=import names + external call sites"
249
+ + ("" if evidence.exception_exits_exported else
250
+ "; exception exits are not exported by this graph"))
251
+ return text if count_text_tokens(text) <= budget else ""
252
+
253
+
254
+ def graph_prompt_lines(graph: Any, paths: list[str], *, embed: bool,
255
+ inventory: dict[str, dict] | None = None,
256
+ chunks: list[dict] | None = None,
257
+ source_tokens: int | None = DEFAULT_GRAPH_MAX_TOKENS,
258
+ max_tokens: int | None = DEFAULT_GRAPH_MAX_TOKENS,
259
+ graph_file: str | None = None) -> list[str]:
260
+ """Status notices retain their original meanings; provided evidence is bounded."""
261
+ state, detail = graph_state(graph)
262
+ if state == "absent":
263
+ return ["GRAPH: not provided for this run. Do not infer imports, calls or data flow; state the "
264
+ "boundary in 'uncertainties' when a relation would matter.", ""]
265
+ if state == "unsupported":
266
+ return [f"GRAPH: unsupported for this run ({detail}). Do not infer relations from the graph; "
267
+ "state unproven boundaries in 'uncertainties'.", ""]
268
+ if state == "failed":
269
+ return [f"GRAPH: analysis failed or was unavailable ({detail}). Treat relations as unknown, not "
270
+ "absent, and do not invent them.", ""]
271
+ if state == "empty":
272
+ return ["GRAPH: provided and successfully empty (the analyzer ran and found no node or relation). "
273
+ "An empty graph is a verified result, not a missing graph; still state any unproven boundary.", ""]
274
+ if not any(n.get("path") in set(paths) for n in graph.get("nodes", [])):
275
+ return [f"GRAPH: provided for this run ({detail}) but no graph node anchors the listed file(s). This is "
276
+ "not an empty graph and not a missing graph: the analyzer produced no symbol node for these "
277
+ "paths. Do not infer either the presence or the absence of relations from it; keep unproven "
278
+ "boundaries in 'uncertainties'.", ""]
279
+ evidence = unit_graph(graph, paths, inventory or {}, chunks or [])
280
+ limits = [limit for limit in (max_tokens, source_tokens) if limit is not None]
281
+ budget = max(0, min(limits)) if limits else None
282
+ text = graph_text(evidence, budget=budget)
283
+ if text:
284
+ access = []
285
+ if budget is not None:
286
+ if embed:
287
+ access = ["Graph access: this completion is tool-free; omitted evidence cannot be inspected.", ""]
288
+ elif graph_file:
289
+ access = [f"Graph access: Read the complete evidence for this batch at {graph_file}", ""]
290
+ return access + [text, ""]
291
+ # Like absent/failed graph notices, this is status, not relation payload.
292
+ # A hard zero/tiny payload cap must never imply there were no relations.
293
+ omitted = Counter()
294
+ for row in evidence.rows:
295
+ if row.count:
296
+ omitted[row.category] += row.count
297
+ notice = (f"GRAPH STATUS (outside payload cap): budget={budget} tokens; omitted "
298
+ + ",".join(f"{kind}={count}" for kind, count in sorted(omitted.items()))
299
+ + f"; internal={evidence.internal_calls}; unmapped={evidence.unmapped_calls}; "
300
+ + f"unmapped_cfg={evidence.unmapped_cfg}; dependencies unit=import names + external call sites."
301
+ + ("" if evidence.exception_exits_exported else
302
+ " exception exits are not exported by this graph."))
303
+ if not embed and graph_file:
304
+ notice += f" Read full evidence: {graph_file}"
305
+ return [notice, ""]