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/checks.py ADDED
@@ -0,0 +1,390 @@
1
+ """Strict checks and shape repair for every task result.
2
+
3
+ Detail answers are decoded, not validated: see ``decode_details``. Other tasks
4
+ allow 2 answers; on the final invalid answer, the normalizer makes usable content
5
+ compliant and returns flags describing every change, or fails when the answer
6
+ cannot be accepted. Retained obligations: the model never emits line numbers (the
7
+ program derives published ranges from the inventory); every inventory id gets
8
+ exactly one disposition (unit / grouped / index_only), written by the program, so
9
+ nothing is silently dropped; length and count are never repair triggers; the
10
+ ownership tree is single-rooted and acyclic for containment while dependency
11
+ cycles stay allowed; each unit has exactly one primary owner.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Any
20
+
21
+
22
+ def json_object(text: str) -> dict[str, Any]:
23
+ stripped = text.strip()
24
+ if stripped.startswith("```"):
25
+ stripped = re.sub(r"^```(?:json)?\s*", "", stripped)
26
+ stripped = re.sub(r"\s*```$", "", stripped).strip()
27
+ value = json.loads(stripped)
28
+ if not isinstance(value, dict):
29
+ raise ValueError("completion must be one JSON object")
30
+ return value
31
+
32
+
33
+ UNIT_KINDS = {"class", "function", "method", "group", "module_region"}
34
+ RELATION_KINDS = {"call", "inherit", "override", "import", "raise", "use",
35
+ "contract", "group"}
36
+ EXTRA_TOPICS = {"condition", "failure", "side_effect", "field", "contract"}
37
+
38
+
39
+ def system_limit() -> int:
40
+ """The overview is 2 to 4 sentences; the entry page has a token budget."""
41
+ return 1200
42
+
43
+
44
+ DEPENDENCY_EVIDENCE_WORDS = re.compile(
45
+ r"\b(?:graph[-_ ]?(?:confirmed|only)|model[-_ ]?inferred)\b"
46
+ r"|\bresolved\s+(?:calls?|edges?|imports?)\b"
47
+ r"|\b\d+\s+(?:(?:static|resolved|call|import)\s+)*(?:calls?|edges?)\b"
48
+ r"|\b(?:within[-_ ]containment|containment)\b"
49
+ r"|图确认|模型推断|图有未描述|包含关系|包含内|\d+\s*条(?:调用|边)", re.IGNORECASE)
50
+
51
+
52
+ def check_system(value: dict[str, Any], limit: int = 2000, *, unit_ids=None, dependencies=None,
53
+ has_candidates: bool = False, normalized: bool = False) -> None:
54
+ errors = []
55
+ if not isinstance(value.get("overview"), str) or not value["overview"].strip() or len(value["overview"]) > limit:
56
+ errors.append(f"overview must be a nonempty concise string of at most {limit} characters")
57
+ descriptions = value.get("dependency_descriptions")
58
+ flows = value.get("key_flows")
59
+ if not isinstance(descriptions, list):
60
+ errors.append("dependency_descriptions must be a list")
61
+ descriptions = []
62
+ if not isinstance(flows, list):
63
+ errors.append("key_flows must be a list")
64
+ flows = []
65
+ pairs = None if dependencies is None else {(e["from"], e["to"]) for e in dependencies}
66
+ for row in descriptions:
67
+ if (not isinstance(row, dict) or not isinstance(row.get("from"), str)
68
+ or not isinstance(row.get("to"), str) or not isinstance(row.get("description"), str)
69
+ or not row["description"].strip() or len(row["description"]) > 300
70
+ or (pairs is not None and (row["from"], row["to"]) not in pairs)):
71
+ errors.append("dependency description must name an existing pair and one sentence (<=300 characters)")
72
+ continue
73
+ if DEPENDENCY_EVIDENCE_WORDS.search(row["description"]):
74
+ errors.append(f"dependency {row['from']} -> {row['to']}: describe who uses whom for what; "
75
+ "omit evidence/source words and edge counts (the program supplies them)")
76
+ if pairs and not descriptions and not normalized:
77
+ errors.append("describe at least one existing dependency")
78
+ if (has_candidates and not (normalized and len(flows) <= 7) and not 3 <= len(flows) <= 7) or (not has_candidates and flows):
79
+ errors.append("key_flows must contain 3-7 flows when candidates exist, otherwise []")
80
+ flow_errors = []
81
+ flow_ids = []
82
+ for index, row in enumerate(flows):
83
+ if (not isinstance(row, dict) or any(not isinstance(row.get(k), str) or not row[k].strip()
84
+ or len(row[k]) > 300 for k in ("name", "description"))
85
+ or not isinstance(row.get("steps"), list) or not 2 <= len(row["steps"]) <= 8
86
+ or any(not isinstance(uid, str) or (unit_ids is not None and uid not in unit_ids)
87
+ for uid in row["steps"])):
88
+ bad_ids = ([uid for uid in row.get("steps", [])
89
+ if not isinstance(uid, str) or (unit_ids is not None and uid not in unit_ids)]
90
+ if isinstance(row, dict) and isinstance(row.get("steps"), list) else [])
91
+ name = row.get("name", "") if isinstance(row, dict) else ""
92
+ flow_errors.append(f"key flow {index + 1} {name!r}: invalid unit ids {bad_ids!r}")
93
+ flow_ids.append(f"key flow {index + 1}: invalid unit ids {bad_ids!r}")
94
+ if flow_errors:
95
+ hint = ("each flow needs name, one sentence, and 2-8 existing unit ids; "
96
+ "steps are unit ids from candidates, not node ids")
97
+ # The host returns only the first 2,000 characters to the model. Put all
98
+ # offending ids first; drop names before losing a later flow's ids.
99
+ if len("; ".join(flow_errors + [hint] + errors)) > 2000:
100
+ flow_errors = flow_ids
101
+ errors = flow_errors + [hint] + errors
102
+ if errors:
103
+ raise ValueError("; ".join(errors))
104
+
105
+
106
+ def check_review(value: dict[str, Any]) -> None:
107
+ if value.get("decision") not in {"accepted", "needs_repair"}:
108
+ raise ValueError("review decision must be accepted or needs_repair")
109
+ if not isinstance(value.get("issues"), list) or any(not isinstance(x, str) for x in value["issues"]):
110
+ raise ValueError("issues must be a list of strings")
111
+
112
+
113
+ def truncate(text: str, limit: int) -> str:
114
+ if len(text) <= limit:
115
+ return text
116
+ cut = text[:max(1, limit - 1)]
117
+ floor = int(limit * 0.7)
118
+ boundary = max((cut.rfind(mark) + len(mark) for mark in (". ", "。", "\n", "; ")
119
+ if cut.rfind(mark) >= floor), default=-1)
120
+ if boundary < 0:
121
+ boundary = cut.rfind(" ") if cut.rfind(" ") >= floor else len(cut)
122
+ return cut[:boundary].rstrip() + "…"
123
+
124
+
125
+ def _as_text(value: Any) -> str:
126
+ if value is None:
127
+ return ""
128
+ if isinstance(value, str):
129
+ return value
130
+ if isinstance(value, (list, dict)):
131
+ return json.dumps(value, ensure_ascii=False, indent=2)
132
+ return str(value)
133
+
134
+
135
+ def _fit(value: dict[str, Any], key: str, limit: int, flags: list[str], *, label: str | None = None) -> None:
136
+ label = label or key
137
+ text = value.get(key)
138
+ if not isinstance(text, str):
139
+ if text is not None:
140
+ flags.append(f"{label} was {type(text).__name__}; converted to text")
141
+ text = _as_text(text)
142
+ if len(text) > limit:
143
+ flags.append(f"{label} truncated from {len(text)} to {limit} characters")
144
+ text = truncate(text, limit)
145
+ value[key] = text
146
+
147
+
148
+ def normalize_system(value: Any, limit: int, *, unit_ids=None, dependencies=None,
149
+ has_candidates: bool = False) -> list[str]:
150
+ if not isinstance(value, dict):
151
+ raise ValueError("overview result is not a JSON object")
152
+ flags: list[str] = []
153
+ _fit(value, "overview", limit, flags)
154
+ if not value["overview"].strip():
155
+ raise ValueError("overview result has no overview")
156
+ # Preserve each independently valid row after the final rejected answer.
157
+ for key in ("dependency_descriptions", "key_flows"):
158
+ rows = value.get(key)
159
+ if not isinstance(rows, list):
160
+ value[key] = []
161
+ flags.append(f"{key} normalized to empty: must be a list")
162
+ continue
163
+ retained = []
164
+ for index, row in enumerate(rows):
165
+ probe = {**value, "dependency_descriptions": [], "key_flows": []}
166
+ probe[key] = [row]
167
+ try:
168
+ check_system(probe, limit, unit_ids=unit_ids, dependencies=dependencies,
169
+ has_candidates=has_candidates, normalized=True)
170
+ except ValueError as exc:
171
+ if key == "key_flows":
172
+ reason = str(exc).replace("key flow 1 ", f"key flow {index + 1} ")
173
+ flags.append(f"key_flows[{index + 1}] removed: {reason}")
174
+ else:
175
+ flags.append(f"{key}[{index + 1}] removed: {exc}")
176
+ else:
177
+ retained.append(row)
178
+ if key == "key_flows" and len(retained) > 7:
179
+ flags.append(f"key_flows trimmed from {len(retained)} to 7")
180
+ retained = retained[:7]
181
+ value[key] = retained
182
+ if key == "key_flows" and has_candidates and len(retained) < 3:
183
+ flags.append(f"key_flows retained {len(retained)} valid flows (fewer than 3) after final invalid answer")
184
+ elif not retained and dependencies and key == "dependency_descriptions":
185
+ flags.append("dependency_descriptions empty after final invalid answer")
186
+ check_system(value, limit, unit_ids=unit_ids, dependencies=dependencies,
187
+ has_candidates=has_candidates, normalized=True)
188
+ return flags
189
+
190
+
191
+ def normalize_review(value: Any) -> list[str]:
192
+ if not isinstance(value, dict):
193
+ raise ValueError("review result is not a JSON object")
194
+ flags: list[str] = []
195
+ issues = value.get("issues")
196
+ if not isinstance(issues, list):
197
+ issues = [] if issues in (None, "") else [_as_text(issues)]
198
+ flags.append("issues was not a list")
199
+ value["issues"] = [_as_text(item) for item in issues]
200
+ if value.get("decision") not in {"accepted", "needs_repair"}:
201
+ value["decision"] = "needs_repair" if value["issues"] else "accepted"
202
+ flags.append(f"decision was invalid; treated as {value['decision']}")
203
+ check_review(value)
204
+ return flags
205
+
206
+
207
+ def _normalize_choice(value: Any, allowed: "set[str] | frozenset[str]",
208
+ default: str) -> tuple[str, bool]:
209
+ """Conservative enum repair: keep a valid value, unwrap a one-element list
210
+ the model sometimes emits (``["use"]``), otherwise fall back to a safe
211
+ default. Returns (value, changed)."""
212
+ if isinstance(value, str) and value in allowed:
213
+ return value, False
214
+ if isinstance(value, list) and len(value) == 1 and isinstance(value[0], str) \
215
+ and value[0] in allowed:
216
+ return value[0], True
217
+ return default, True
218
+
219
+
220
+ # ---------------------------------------------------------------- detail answers
221
+ #
222
+ # A detail answer names symbols by the short numbers of its batch table
223
+ # (``symbols.batch_table``) and never writes an inventory id. ``decode_details``
224
+ # turns it into the internal shape every later stage reads: ``units`` with
225
+ # ``id``/``kind``/``members`` and a complete ``dispositions`` map. It accepts
226
+ # what it can, unit by unit; only a non-object answer or a missing ``units``
227
+ # list voids the whole answer.
228
+
229
+ #: A function, method or class whose own source has at least this many tokens and
230
+ #: that no unit mentions is a gap to re-ask, not an automatic index_only entry.
231
+ GAP_MIN_TOKENS = 40
232
+ GAP_KINDS = {"function", "method", "class"}
233
+ NOT_EXPLAINED = "not explained; indexed without prose"
234
+
235
+
236
+ @dataclass
237
+ class Decoded:
238
+ """What one answer added: accepted units, notes on repairs, units rejected."""
239
+ units: list[dict[str, Any]] = field(default_factory=list)
240
+ flags: list[str] = field(default_factory=list)
241
+ invalid: list[dict[str, Any]] = field(default_factory=list)
242
+ notes: str = ""
243
+
244
+
245
+ def _number(value: Any) -> int | None:
246
+ if type(value) is int:
247
+ return value
248
+ if isinstance(value, str) and value.strip().isdigit():
249
+ return int(value.strip())
250
+ return None
251
+
252
+
253
+ def covered_ids(units: list[dict[str, Any]], table: list[dict]) -> set[str]:
254
+ """Inventory ids that some unit explains (its own id or a member)."""
255
+ scope = {row["id"] for row in table}
256
+ return {sid for unit in units for sid in [unit["id"], *(unit.get("members") or [])] if sid in scope}
257
+
258
+
259
+ def _kebab(text: str) -> str:
260
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
261
+
262
+
263
+ def _group_id(title: Any, first: dict, taken: set[str]) -> str:
264
+ base = _kebab(title) if isinstance(title, str) else ""
265
+ base = base or _kebab(first["name"]) or "group"
266
+ stem = f"grp:{first['path'].rsplit('/', 1)[-1]}-{base}"
267
+ uid, count = stem, 1
268
+ while uid in taken:
269
+ count += 1
270
+ uid = f"{stem}-{count}"
271
+ return uid
272
+
273
+
274
+ def _decode_unit(raw: dict[str, Any], rows: list[dict], by_number: dict[int, dict], taken: set[str]) -> dict[str, Any]:
275
+ """The internal unit for one answer unit whose ``h`` and ``explain`` are valid."""
276
+ first = rows[0]
277
+ if len(rows) == 1:
278
+ unit: dict[str, Any] = {"id": first["id"], "kind": first["kind"]}
279
+ else:
280
+ unit = {"id": _group_id(raw.get("title"), first, taken), "kind": "group",
281
+ "members": [row["id"] for row in rows]}
282
+ unit["explain"] = raw["explain"].strip()
283
+ unit["extras"] = [{"topic": _normalize_choice(extra.get("topic"), EXTRA_TOPICS, "contract")[0],
284
+ "note": extra["note"].strip()}
285
+ for extra in raw.get("extras") or []
286
+ if isinstance(extra, dict) and isinstance(extra.get("note"), str) and extra["note"].strip()]
287
+ relations = []
288
+ for rel in raw.get("relations") or []:
289
+ if not isinstance(rel, dict):
290
+ continue
291
+ number = _number(rel.get("to"))
292
+ target = by_number[number]["id"] if number in by_number else (
293
+ rel["to"].strip() if number is None and isinstance(rel.get("to"), str) and rel["to"].strip() else None)
294
+ if target is None:
295
+ continue
296
+ relation = {"target": target, "kind": _normalize_choice(rel.get("kind", "use"), RELATION_KINDS, "use")[0]}
297
+ if isinstance(rel.get("basis"), str):
298
+ relation["basis"] = rel["basis"]
299
+ relations.append(relation)
300
+ unit["relations"] = relations
301
+ unit["uncertainties"] = [item for item in raw.get("uncertainties") or [] if isinstance(item, str)]
302
+ return unit
303
+
304
+
305
+ def decode_details(payload: Any, table: list[dict], *, have: list[dict[str, Any]] | None = None,
306
+ known_ids: set[str] | frozenset[str] = frozenset()) -> Decoded:
307
+ """Decode one detail answer against its batch table.
308
+
309
+ ``have`` holds units accepted from earlier answers of the same task; their
310
+ symbols and ids stay taken. A unit with an unknown short number loses that
311
+ number; a number already in an earlier unit stays with the first (a unit whose
312
+ symbols are all taken is ignored); a unit left without any valid number or
313
+ without an explanation is returned in ``invalid`` and the other units are kept. Raises ``ValueError`` only when the whole answer is unusable."""
314
+ if not isinstance(payload, dict):
315
+ raise ValueError("answer is not a JSON object")
316
+ rows = payload.get("units")
317
+ if not isinstance(rows, list):
318
+ raise ValueError("answer has no units list")
319
+ have = have or []
320
+ by_number = {row["n"]: row for row in table}
321
+ by_id = {row["id"]: row for row in table}
322
+ taken_symbols = covered_ids(have, table)
323
+ taken_ids = {unit["id"] for unit in have} | set(known_ids)
324
+ out = Decoded()
325
+ for index, raw in enumerate(rows):
326
+ label = f"unit {index + 1}"
327
+ if not isinstance(raw, dict):
328
+ out.invalid.append({"h": [], "why": f"{label} is not an object"})
329
+ continue
330
+ listed = raw.get("h")
331
+ numbers: list[int] = []
332
+ for item in listed if isinstance(listed, list) else []:
333
+ number = _number(item)
334
+ if number not in by_number:
335
+ out.flags.append(f"{label}: unknown symbol number {item!r} removed")
336
+ elif number not in numbers:
337
+ numbers.append(number)
338
+ symbols = []
339
+ for number in numbers:
340
+ row = by_number[number]
341
+ if row["id"] in taken_symbols:
342
+ out.flags.append(f"{label}: symbol {number} is already in an earlier unit; kept there")
343
+ else:
344
+ symbols.append(row)
345
+ shown = [row["n"] for row in symbols]
346
+ if not numbers:
347
+ out.invalid.append({"h": [], "why": f"{label} lists no symbol number of this batch"})
348
+ continue
349
+ if not symbols: # a repeat of explained symbols loses nothing; it is not worth a re-ask
350
+ out.flags.append(f"{label}: every symbol is already in an earlier unit; ignored")
351
+ continue
352
+ if not isinstance(raw.get("explain"), str) or not raw["explain"].strip():
353
+ out.invalid.append({"h": shown, "why": f"{label} has an empty explain"})
354
+ continue
355
+ unit = _decode_unit(raw, symbols, by_number, taken_ids)
356
+ taken_symbols |= {row["id"] for row in symbols}
357
+ taken_ids.add(unit["id"])
358
+ out.units.append(unit)
359
+ if isinstance(payload.get("notes"), str):
360
+ out.notes = payload["notes"].strip()
361
+ return out
362
+
363
+
364
+ def find_gaps(table: list[dict], units: list[dict[str, Any]], tokens: dict[str, int]) -> list[dict]:
365
+ """Symbols with logic that no unit mentions: functions, methods and classes
366
+ outside test files whose own source is at least ``GAP_MIN_TOKENS`` tokens.
367
+ Everything else that no unit mentions is simply indexed without prose."""
368
+ done = covered_ids(units, table)
369
+ return [row for row in table
370
+ if row["id"] not in done and row["kind"] in GAP_KINDS
371
+ and tokens.get(row["id"], 0) >= GAP_MIN_TOKENS]
372
+
373
+
374
+ def assemble_details(table: list[dict], units: list[dict[str, Any]], *, notes: str = "") -> dict[str, Any]:
375
+ """The internal payload: units plus a complete ``dispositions`` map.
376
+
377
+ A symbol inside a unit is ``unit`` (the unit is the symbol) or ``grouped``;
378
+ every other symbol is ``index_only``."""
379
+ dispositions: dict[str, dict[str, Any]] = {}
380
+ for unit in units:
381
+ if unit["kind"] == "group":
382
+ dispositions.update({sid: {"how": "grouped", "unit": unit["id"]} for sid in unit["members"]})
383
+ else:
384
+ dispositions[unit["id"]] = {"how": "unit", "unit": unit["id"]}
385
+ for row in table:
386
+ dispositions.setdefault(row["id"], {"how": "index_only", "unit": None, "reason": NOT_EXPLAINED})
387
+ payload: dict[str, Any] = {"units": units, "dispositions": dispositions}
388
+ if notes:
389
+ payload["batch_notes"] = notes
390
+ return payload