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/batching.py ADDED
@@ -0,0 +1,283 @@
1
+ """Source scan and reading batches: which files are read together by one detail author.
2
+
3
+ A batch (``Module``) is a reading unit, not a semantic module. Batches keep a
4
+ subsystem and its tests apart, keep fitting directory subtrees whole, and join
5
+ files linked by imports first, within a token target per batch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import hashlib
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from cbe import grammars
18
+ from cbe.token_budget import count_text_tokens
19
+
20
+
21
+ class BatchingError(ValueError):
22
+ """The repository cannot be split into reading batches."""
23
+
24
+
25
+ TARGET_TOKENS = 20_000
26
+ MAX_TOKENS = 25_000
27
+ MAX_SINGLE_FILE_TOKENS = 60_000
28
+
29
+
30
+ def is_test_path(path: str) -> bool:
31
+ return path.startswith(("tests/", "test/", "t/")) or "/test_" in path
32
+
33
+
34
+ @dataclass
35
+ class SourceFile:
36
+ path: str
37
+ source: str
38
+ tokens: int
39
+ digest: str
40
+ imports: set[str] = field(default_factory=set)
41
+
42
+ @property
43
+ def is_test(self) -> bool:
44
+ return is_test_path(self.path)
45
+
46
+
47
+ @dataclass
48
+ class Module:
49
+ number: int
50
+ files: list[SourceFile]
51
+ bucket: str
52
+ title: str = ""
53
+
54
+ @property
55
+ def tokens(self) -> int:
56
+ return sum(item.tokens for item in self.files)
57
+
58
+ @property
59
+ def is_test(self) -> bool:
60
+ return all(item.is_test for item in self.files)
61
+
62
+
63
+ def _imports(path: str, source: str) -> set[str]:
64
+ names: set[str] = set()
65
+ if path.endswith(".py"):
66
+ try:
67
+ tree = ast.parse(source)
68
+ except SyntaxError:
69
+ return names
70
+ for node in ast.walk(tree):
71
+ if isinstance(node, ast.Import):
72
+ names.update(alias.name for alias in node.names)
73
+ elif isinstance(node, ast.ImportFrom) and node.module:
74
+ names.add(node.module)
75
+ names.update(node.module + "." + alias.name for alias in node.names)
76
+ else:
77
+ names.update(re.findall(r"#include\s*[\"<]([^\">]+)[\">]", source))
78
+ names.update(re.findall(r"\bfrom\s*[\"']([^\"']+)[\"']", source))
79
+ names.update(re.findall(r"\brequire\s*\(\s*[\"']([^\"']+)[\"']", source))
80
+ return names
81
+
82
+
83
+ def scan(repo: Path) -> list[SourceFile]:
84
+ """Every regular source file outside the excluded directories; symlinks are never followed."""
85
+ suffixes = grammars.registered_suffixes() | grammars.FALLBACK_SUFFIXES
86
+ files: list[SourceFile] = []
87
+ for path in sorted(repo.rglob("*")):
88
+ if not path.is_file() or path.is_symlink() or path.suffix.lower() not in suffixes:
89
+ continue
90
+ if any(part in grammars.DEFAULT_EXCLUDE_DIR_NAMES for part in path.relative_to(repo).parts):
91
+ continue
92
+ raw = path.read_bytes()
93
+ try:
94
+ source = raw.decode("utf-8")
95
+ except UnicodeDecodeError as exc:
96
+ raise BatchingError(f"source is not UTF-8: {path.relative_to(repo)}") from exc
97
+ rel = path.relative_to(repo).as_posix()
98
+ files.append(SourceFile(rel, source, count_text_tokens(source), hashlib.sha256(raw).hexdigest(),
99
+ _imports(rel, source)))
100
+ if not files:
101
+ raise BatchingError("no supported source files")
102
+ return files
103
+
104
+
105
+ def _subsystem(item: SourceFile, production_areas: set[str],
106
+ production_files: list[SourceFile], primary_root: str) -> str:
107
+ parts = Path(item.path).parts
108
+ if not item.is_test:
109
+ if parts[0] != primary_root:
110
+ return parts[0]
111
+ return parts[1] if len(parts) > 2 else "core"
112
+
113
+ # A test's physical directory remains its packing boundary. The matching
114
+ # source subsystem only decides where its page appears in the index.
115
+ ignored = {"t", "test", "tests", "unit", "integration", "smoke", "benchmarks"}
116
+ for part in parts[1:-1]:
117
+ if part in ignored:
118
+ continue
119
+ if part in production_areas:
120
+ return part
121
+ for area in sorted(production_areas):
122
+ if part.rstrip("s") == area.rstrip("s"):
123
+ return area
124
+ local_area = next((part for part in parts[1:-1] if part not in ignored), "core")
125
+ stem = Path(item.path).stem.removeprefix("test_").removesuffix("_test")
126
+ if stem in {"__init__", "conftest"}:
127
+ return local_area
128
+ words = set(stem.split("_"))
129
+ for area in sorted(production_areas):
130
+ if area.rstrip("s") in {word.rstrip("s") for word in words}:
131
+ return area
132
+ matches = [(len(Path(source.path).parts),
133
+ _subsystem(source, production_areas, [], primary_root))
134
+ for source in production_files
135
+ if Path(source.path).stem == stem or
136
+ Path(source.path).stem.rstrip("s") == stem.rstrip("s")]
137
+ if matches:
138
+ # A top-level implementation is a better default than an equally
139
+ # named CLI adapter or example nested several directories below it.
140
+ return min(matches)[1]
141
+ return local_area
142
+
143
+
144
+ def _pack_directory(files: list[SourceFile], *, strict: bool = False,
145
+ target_tokens: int = TARGET_TOKENS, max_tokens: int = MAX_TOKENS) -> list[list[SourceFile]]:
146
+ """Join imports first, then fill only within a specific source directory."""
147
+ groups = {i: [item] for i, item in enumerate(sorted(files, key=lambda item: item.path))}
148
+ tokens = {i: group[0].tokens for i, group in groups.items()}
149
+ candidates: dict[str, set[int]] = {}
150
+ for i, group in groups.items():
151
+ path = Path(group[0].path)
152
+ for name in {group[0].path, path.name, path.stem, ".".join(path.with_suffix("").parts),
153
+ ".".join(path.with_suffix("").parts[1:])}:
154
+ candidates.setdefault(name, set()).add(i)
155
+ links: dict[int, dict[int, int]] = {i: {} for i in groups}
156
+ for i, group in groups.items():
157
+ targets: set[int] = set()
158
+ for ref in group[0].imports:
159
+ # Exact names and nonempty dotted suffixes implement the original
160
+ # candidate match; a set counts each directed file pair just once.
161
+ keys = {ref} | {ref[pos + 1:] for pos, char in enumerate(ref)
162
+ if char == "." and ref[pos + 1:]}
163
+ for key in keys:
164
+ targets.update(candidates.get(key, ()))
165
+ for j in targets - {i}:
166
+ links[i][j] = links[i].get(j, 0) + 1
167
+ links[j][i] = links[j].get(i, 0) + 1
168
+ while True:
169
+ pairs = []
170
+ ids = list(groups)
171
+ for offset, left in enumerate(ids):
172
+ for right in ids[offset + 1:]:
173
+ combined_tokens = tokens[left] + tokens[right]
174
+ if combined_tokens > max_tokens:
175
+ continue
176
+ weight = links[left].get(right, 0)
177
+ if weight:
178
+ # Stable ids retain group order after removals, hence the
179
+ # same left/right tie break as the original list indices.
180
+ pairs.append((weight, -abs(target_tokens - combined_tokens),
181
+ -left, -right, left, right))
182
+ if not pairs:
183
+ break
184
+ *_, left, right = max(pairs)
185
+ groups[left].extend(groups.pop(right))
186
+ tokens[left] += tokens.pop(right)
187
+ for other in groups:
188
+ if other == left:
189
+ continue
190
+ weight = links[left].get(other, 0) + links[right].get(other, 0)
191
+ if weight:
192
+ links[left][other] = links[other][left] = weight
193
+ links[other].pop(right, None)
194
+ links[left].pop(right, None)
195
+ links.pop(right)
196
+ if strict:
197
+ return list(groups.values())
198
+ packed: list[list[SourceFile]] = []
199
+ for group in sorted(groups.values(), key=lambda group: (-sum(item.tokens for item in group), group[0].path)):
200
+ candidates = [existing for existing in packed
201
+ if sum(item.tokens for item in existing + group) <= max_tokens]
202
+ if candidates:
203
+ chosen = max(candidates, key=lambda existing: (
204
+ min(sum(item.tokens for item in existing + group), target_tokens),
205
+ -packed.index(existing)))
206
+ chosen.extend(group)
207
+ else:
208
+ packed.append(group)
209
+ return packed
210
+
211
+
212
+ def _directory_groups(files: list[SourceFile], *, strict_root: Path | None = None,
213
+ target_tokens: int = TARGET_TOKENS, max_tokens: int = MAX_TOKENS) -> list[list[SourceFile]]:
214
+ """Keep fitting subtrees whole; recurse only when a subtree is oversized."""
215
+ if sum(item.tokens for item in files) <= max_tokens:
216
+ return [files]
217
+ parents = {Path(item.path).parent for item in files}
218
+ common = Path(os.path.commonpath([str(parent) for parent in parents]))
219
+ direct = [item for item in files if Path(item.path).parent == common]
220
+ children: dict[str, list[SourceFile]] = {}
221
+ for item in files:
222
+ if item in direct:
223
+ continue
224
+ relative = Path(item.path).relative_to(common)
225
+ children.setdefault(relative.parts[0], []).append(item)
226
+ groups = _pack_directory(direct, strict=common == strict_root,
227
+ target_tokens=target_tokens, max_tokens=max_tokens)
228
+ child_groups = [_directory_groups(children[name], strict_root=strict_root,
229
+ target_tokens=target_tokens, max_tokens=max_tokens)
230
+ for name in sorted(children)]
231
+ # Small sibling directories may share one module, but never cross an area
232
+ # or a test/production boundary (the caller already partitioned both).
233
+ small = [group for sibling in child_groups for group in sibling
234
+ if sum(item.tokens for item in group) < 4_000]
235
+ groups.extend(group for sibling in child_groups for group in sibling
236
+ if sum(item.tokens for item in group) >= 4_000)
237
+ if small:
238
+ groups.extend(_pack_directory([item for group in small for item in group],
239
+ target_tokens=target_tokens, max_tokens=max_tokens))
240
+ return groups
241
+
242
+
243
+ def plan(files: list[SourceFile], *, target_tokens: int = TARGET_TOKENS,
244
+ max_tokens: int = MAX_TOKENS) -> list[Module]:
245
+ if target_tokens < 1 or max_tokens < target_tokens:
246
+ raise BatchingError("batch limits require 0 < target_tokens <= max_tokens")
247
+ for item in files:
248
+ if item.tokens > MAX_SINGLE_FILE_TOKENS:
249
+ raise BatchingError(f"one whole source file exceeds {MAX_SINGLE_FILE_TOKENS} tokens: {item.path}")
250
+ production = [item for item in files if not item.is_test]
251
+ root_tokens: dict[str, int] = {}
252
+ for item in production:
253
+ root = Path(item.path).parts[0]
254
+ root_tokens[root] = root_tokens.get(root, 0) + item.tokens
255
+ primary_root = max(root_tokens, key=root_tokens.get) if root_tokens else ""
256
+ production_areas = {(_subsystem(item, set(), [], primary_root)) for item in production}
257
+ partitions: dict[tuple[str, bool], list[SourceFile]] = {}
258
+ for item in files:
259
+ area = _subsystem(item, production_areas, production, primary_root)
260
+ partitions.setdefault((area, item.is_test), []).append(item)
261
+ bins: list[Module] = []
262
+ for (area, is_test), members in sorted(partitions.items()):
263
+ strict_root = Path(primary_root) if area == "core" and not is_test else None
264
+ for group in _directory_groups(members, strict_root=strict_root,
265
+ target_tokens=target_tokens, max_tokens=max_tokens):
266
+ bins.append(Module(len(bins), sorted(group, key=lambda item: item.path), area))
267
+ return bins
268
+
269
+
270
+ def fallback_title(module: Module) -> str:
271
+ """A batch label a person can recognise in the progress log: directory plus its first files."""
272
+ directory = primary_directory(module)
273
+ names = sorted(Path(item.path).name for item in module.files)
274
+ more = f" (+{len(names) - 2})" if len(names) > 2 else ""
275
+ return f"{directory or '.'}: {', '.join(names[:2])}{more}"[:80]
276
+
277
+
278
+ def primary_directory(module: Module) -> str:
279
+ counts: dict[str, int] = {}
280
+ for item in module.files:
281
+ directory = Path(item.path).parent.as_posix()
282
+ counts[directory] = counts.get(directory, 0) + 1
283
+ return min(counts, key=lambda path: (-counts[path], path))
cbe/catalog_query.py ADDED
@@ -0,0 +1,376 @@
1
+ """Read-side queries over a rendered run's ``catalog.json``.
2
+
3
+ ``find``, ``query`` and ``source`` back the CLI lookup commands;
4
+ ``layer_metrics`` measures the storage layers of a run.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from cbe.symbols import SemanticError
15
+
16
+ RUN_VERSION_ERROR = "this run directory was made by a different version of cbe; start a new plan"
17
+
18
+
19
+ def catalog_symbols(catalog: dict) -> dict[str, dict]:
20
+ """Resolve the current catalog's public search view.
21
+
22
+ Inventory IDs retain their names/facts. The semantic index supplies their
23
+ disposition and primary page; synthetic units are also first-class query
24
+ targets.
25
+ """
26
+ records = {key: dict(row) for key, row in (catalog.get("symbols") or {}).items()}
27
+ for uid, unit in (catalog.get("units") or {}).items():
28
+ record = {**records.get(uid, {}), **unit}
29
+ record.setdefault("name", (catalog.get("short_names") or {}).get(uid, uid))
30
+ paths = sorted({row["path"] for row in unit.get("ranges") or [] if row.get("path")})
31
+ record.setdefault("files", paths)
32
+ if len(paths) == 1:
33
+ record.setdefault("file", paths[0])
34
+ records[uid] = record
35
+ for sid, resolution in catalog["symbol_index"].items():
36
+ records[sid] = {**records.get(sid, {}),
37
+ **(catalog.get("dispositions") or {}).get(sid, {}),
38
+ **resolution}
39
+ for sid, record in records.items():
40
+ target = record.get("unit") or (sid if sid in (catalog.get("units") or {}) else None)
41
+ detail = (catalog.get("units") or {}).get(target)
42
+ if detail is not None:
43
+ records[sid] = {**detail, **record, "id": sid,
44
+ "unit_id": target, "unit_ranges": detail.get("ranges", [])}
45
+ else:
46
+ record.setdefault("explain", record.get("reason", ""))
47
+ for field in ("signatures", "relations", "reverse_relations", "outgoing", "incoming"):
48
+ record.setdefault(field, [])
49
+ edge_table = catalog.get("edges") or {}
50
+ for sid, record in records.items():
51
+ record["display_name"] = (catalog.get("short_names") or {}).get(sid, record.get("name", sid))
52
+ for direction in ("outgoing", "incoming"):
53
+ record[direction] = [edge_table[e] if isinstance(e, str) else e
54
+ for e in record.get(direction) or []]
55
+ return records
56
+
57
+
58
+ def query_catalog(run_dir: Path) -> dict[str, Any]:
59
+ catalog = json.loads((run_dir / "catalog.json").read_text(encoding="utf-8"))
60
+ if "symbol_index" not in catalog:
61
+ raise SemanticError(RUN_VERSION_ERROR)
62
+ catalog["symbols"] = catalog_symbols(catalog)
63
+ return catalog
64
+
65
+
66
+ def format_ranges(ranges: list[dict]) -> str:
67
+ """Compact, unambiguous source coordinates for agent-facing text."""
68
+ return ", ".join(f"{row['path']}:L{start}-L{end}"
69
+ for row in ranges for start, end in row.get("lines", []))
70
+
71
+
72
+ def find(run_dir: Path, term: str, limit: int = 25) -> list[dict[str, Any]]:
73
+ """Locate matches; detailed evidence stays behind query."""
74
+ from cbe.records import summary_sentence
75
+
76
+ catalog = query_catalog(run_dir)
77
+ inventory = {s["id"]: (path, s) for path, entry in catalog.get("inventory", {}).items()
78
+ for s in entry.get("symbols", [])}
79
+ needle = term.casefold()
80
+ results = []
81
+ candidates = [(sid, record, False) for sid, record in catalog["symbols"].items()]
82
+ candidates += [(path, record, True) for path, record in catalog["files"].items() if record.get("facts")]
83
+ for sid, record, is_file in candidates:
84
+ searchable = {"facts": record["facts"]} if is_file else {"id": sid, **record}
85
+ matched = [field for field, value in searchable.items()
86
+ if needle in json.dumps(value, ensure_ascii=False).casefold()]
87
+ if not matched:
88
+ continue
89
+ path, symbol = inventory.get(sid, (record.get("file"), {}))
90
+ ranges = ([{"path": path, "lines": symbol["lines"]}] if symbol.get("lines") else
91
+ record.get("ranges") or [])
92
+ if is_file and not ranges:
93
+ ranges = [{"path": sid, "lines": [[1, max(1, catalog.get("inventory", {}).get(sid, {}).get("nlines", 1))]]}]
94
+ name = record.get("display_name") or record.get("name") or sid
95
+ if "::" in name:
96
+ name = symbol.get("name") or name
97
+ summary = summary_sentence(record) or summary_sentence({"explain": record.get("reason", "")})
98
+ words = summary.split()
99
+ summary = " ".join(words[:25]) + ("…" if len(words) > 25 else "")
100
+ results.append({"id": sid, "name": name, "kind": symbol.get("kind") or record.get("kind") or
101
+ ("file" if is_file else "symbol"), "location": format_ranges(ranges),
102
+ "summary": summary, "matched_fields": matched})
103
+ results.sort(key=lambda row: (0 if needle in {row["id"].casefold(), row["name"].casefold(),
104
+ str(catalog["symbols"].get(row["id"], {}).get("display_name", "")).casefold()}
105
+ else 1))
106
+ return results[:limit]
107
+
108
+
109
+ def find_text(results: list[dict]) -> str:
110
+ return "".join(f"{r['name']} | {r['id']} | {r['kind']} | {r['location']} | "
111
+ f"{r['summary']} | match: {','.join(r['matched_fields'])}\n"
112
+ for r in results)
113
+
114
+
115
+ def resolve_name(catalog: dict[str, Any], name: str) -> str | None:
116
+ """The one ID a name copied from a page stands for; None when it is no page name.
117
+
118
+ Tried in order: a group unit's name without its ``grp:`` prefix; a unit heading or
119
+ anchor as the page prints it; a symbol name. A step that matches several IDs raises
120
+ with all of them: the caller picks, the program does not guess."""
121
+ term = name.strip().strip("`").strip()
122
+ if not term:
123
+ return None
124
+ units, names = catalog.get("units", {}), catalog.get("short_names", {})
125
+ anchor = term.lstrip("#")
126
+ steps = [
127
+ [f"grp:{term}"] if f"grp:{term}" in catalog["symbols"] else [],
128
+ sorted(uid for uid, unit in units.items()
129
+ if names.get(uid) == term or (unit.get("anchor") or "").lstrip("#") == anchor),
130
+ sorted(sid for sid, record in catalog["symbols"].items()
131
+ if term in {record.get("name"), record.get("display_name"),
132
+ sid.split("::")[2] if sid.count("::") >= 2 else None}),
133
+ ]
134
+ for ids in steps:
135
+ if len(ids) == 1:
136
+ return ids[0]
137
+ if ids:
138
+ raise SemanticError(f"{name} matches {len(ids)} IDs; query one of them:\n" + "\n".join(ids))
139
+ return None
140
+
141
+
142
+ def query(run_dir: Path, symbol_id: str, edges: str | None = None) -> dict[str, Any]:
143
+ """Compact object view; frozen graph is read only for explicit expansion."""
144
+ if edges is not None and edges != "all" and (
145
+ not edges or set(edges.split(",")) - {"import", "call", "cfg", "dataflow"}):
146
+ raise SemanticError("--edges must be all or comma-separated import,call,cfg,dataflow")
147
+ catalog = query_catalog(run_dir)
148
+ asked = symbol_id
149
+ if not any(symbol_id in catalog.get(table, {}) for table in ("nodes", "symbols", "files")):
150
+ symbol_id = resolve_name(catalog, symbol_id) or symbol_id
151
+ table = catalog.get("edges") or {}
152
+ if symbol_id in catalog.get("nodes", {}):
153
+ return {"id": symbol_id, "name": catalog["nodes"][symbol_id]["title"],
154
+ **catalog["nodes"][symbol_id],
155
+ "dependencies": [e for e in catalog.get("node_dependencies", []) if e["from"] == symbol_id],
156
+ "depended_on_by": [e for e in catalog.get("node_dependencies", []) if e["to"] == symbol_id]}
157
+ if symbol_id in catalog["symbols"]:
158
+ record = {"id": symbol_id, **catalog["symbols"][symbol_id]}
159
+ for direction in ("outgoing", "incoming"):
160
+ record[direction] = [table[e] if isinstance(e, str) else e
161
+ for e in record.get(direction) or []]
162
+ elif symbol_id in catalog["files"]:
163
+ def listing(uid, row):
164
+ return {"id": uid, "name": catalog.get("short_names", {}).get(uid, row.get("name", uid)),
165
+ "ranges": row.get("ranges", []), "summary": row.get("summary", row.get("reason", ""))}
166
+ record = {"id": symbol_id, "ranges": catalog["files"][symbol_id].get("ranges", []),
167
+ **{key: catalog["files"][symbol_id][key] for key in ("tokens", "language", "known_empty")
168
+ if key in catalog["files"][symbol_id]},
169
+ "units": [listing(uid, u) for uid, u in catalog.get("units", {}).items()
170
+ if any(r["path"] == symbol_id for r in u.get("ranges") or [])],
171
+ "symbols": [listing(sid, s) for sid, s in catalog["symbols"].items()
172
+ if s.get("file") == symbol_id and sid not in catalog.get("units", {})],
173
+ "facts": catalog["files"][symbol_id].get("facts", {}),
174
+ "imports": [e for e in table.values() if e.get("kind") == "import"
175
+ and e.get("path") == symbol_id]}
176
+ else:
177
+ raise SemanticError(f"unknown unit, symbol or file: {symbol_id}; "
178
+ f"first use cbe find --run-dir {run_dir} --term {symbol_id}")
179
+ record.setdefault("name", symbol_id)
180
+ record["display_name"] = catalog.get("short_names", {}).get(symbol_id, record["name"])
181
+ if asked != symbol_id:
182
+ record["resolved_from"] = asked
183
+ if edges:
184
+ from cbe.records import SourceOwnership, frozen_sources
185
+
186
+ context = json.loads((run_dir / "context.json").read_text(encoding="utf-8"))
187
+ graph = context.get("graph") or catalog.get("graph") or {}
188
+ nodes = {n["id"]: n for n in graph.get("nodes") or []}
189
+ ownership = SourceOwnership(frozen_sources(context), context.get("chunks") or [],
190
+ catalog["symbols"])
191
+ members = ({symbol_id, *record.get("folded_members", [])}
192
+ if symbol_id in catalog.get("units", {}) else {symbol_id})
193
+ def matches(edge):
194
+ if symbol_id in catalog["files"]:
195
+ node = nodes.get(edge.get("source"), {})
196
+ owner = nodes.get(node.get("owner"), node)
197
+ return (edge.get("path") or owner.get("path")) == symbol_id
198
+ return ownership.edge_source(edge, nodes) in members
199
+ from cbe.graph import NO_DATAFLOW_MESSAGE, built_without_dataflow
200
+
201
+ if built_without_dataflow(graph) and "dataflow" in edges.split(","):
202
+ if edges == "dataflow":
203
+ raise SemanticError(NO_DATAFLOW_MESSAGE)
204
+ record["notice"] = NO_DATAFLOW_MESSAGE
205
+ record["raw_edges"] = [e for e in graph.get("edges") or []
206
+ if (edges == "all" or e.get("kind") in edges.split(",")) and matches(e)]
207
+ return record
208
+
209
+
210
+ def query_text(record: dict) -> str:
211
+ """Agent view: prose once, one line per relation or graph edge."""
212
+ from cbe.records import summary_and_remainder
213
+
214
+ summary, remainder = summary_and_remainder(record)
215
+ lines = [f"resolved: {record['resolved_from']} -> {record['id']}"] if record.get("resolved_from") else []
216
+ lines.append(f"{record.get('display_name', record['name'])} {record['id']}")
217
+ if record.get("ungrounded"):
218
+ lines.append(f"check: names {', '.join(record['ungrounded'])}, not found in this unit's source")
219
+ for field in ("dependencies", "depended_on_by", "signature", "signatures", "ranges", "summary", "explain", "extras",
220
+ "uncertainties", "relations", "reverse_relations", "folded_members",
221
+ "internal_edge_counts", "facts", "units", "symbols", "notice"):
222
+ value = record.get(field)
223
+ if not value or (field == "summary" and remainder != (record.get("explain") or "").strip()):
224
+ continue
225
+ if field == "signatures" and len(value) == 1 and value[0]["signature"] == record.get("signature"):
226
+ continue
227
+ if field == "ranges":
228
+ lines.append(f"ranges: {format_ranges(value)}")
229
+ elif field in {"units", "symbols"}:
230
+ lines.extend(f"{field}: {row['name']} | {row['id']} | {format_ranges(row['ranges'])} | {row['summary']}"
231
+ for row in value)
232
+ elif isinstance(value, list):
233
+ lines.extend(f"{field}: {json.dumps(row, ensure_ascii=False)}" for row in value)
234
+ else:
235
+ lines.append(f"{field}: {value}")
236
+ for direction in ("outgoing", "incoming", "imports", "raw_edges"):
237
+ for edge in record.get(direction) or []:
238
+ endpoint = "source" if direction == "incoming" else "target"
239
+ lines.append(f"{direction}: {edge.get('kind')} {edge.get(endpoint + '_name', edge.get(endpoint))} "
240
+ f"{edge.get(endpoint + '_symbol') or edge.get(endpoint)} [{edge.get('status', 'structural')}] "
241
+ f"{edge.get('path', '')}:L{edge.get('line', '')} id={edge['id']}")
242
+ return "\n".join(lines) + "\n"
243
+
244
+
245
+ def source(run_dir: Path, object_id: str, context: int = 0) -> str:
246
+ """Return numbered frozen source, checking all selected files before output."""
247
+ from cbe.records import frozen_sources
248
+
249
+ if context < 0:
250
+ raise SemanticError("--context must be nonnegative")
251
+ record = query(run_dir, object_id)
252
+ ranges = record.get("ranges") or []
253
+ if not ranges:
254
+ raise SemanticError(f"no source ranges for: {object_id}")
255
+ frozen = json.loads((run_dir / "context.json").read_text(encoding="utf-8"))
256
+ texts = frozen_sources(frozen, [row["path"] for row in ranges])
257
+ selected: dict[str, list[list[int]]] = {}
258
+ for row in ranges:
259
+ nlines = len(texts[row["path"]].splitlines())
260
+ for start, end in row["lines"]:
261
+ if nlines == 0:
262
+ selected.setdefault(row["path"], [])
263
+ continue
264
+ if not 1 <= start <= end <= nlines:
265
+ raise SemanticError(f"source range out of bounds: {row['path']}:{start}-{end}")
266
+ selected.setdefault(row["path"], []).append([max(1, start - context), min(nlines, end + context)])
267
+ output = [f"# resolved: {record['resolved_from']} -> {record['id']}"] if record.get("resolved_from") else []
268
+ for path, spans in sorted(selected.items()):
269
+ merged: list[list[int]] = []
270
+ for start, end in sorted(spans):
271
+ if merged and start <= merged[-1][1] + 1:
272
+ merged[-1][1] = max(merged[-1][1], end)
273
+ else:
274
+ merged.append([start, end])
275
+ lines = texts[path].splitlines()
276
+ if not merged:
277
+ output.append(f"# {path}: empty file")
278
+ for start, end in merged:
279
+ output.append(f"# {path}:L{start}-L{end}")
280
+ output.extend(f"{number}: {lines[number - 1]}" for number in range(start, end + 1))
281
+ return "\n".join(output) + "\n"
282
+
283
+
284
+ def layer_metrics(run_dir: Path) -> dict:
285
+ """One storage-based token measure.
286
+
287
+ Markdown includes page metadata, navigation, signatures and usage examples.
288
+ Catalog JSON includes its preserved copies of prose. Frozen graph evidence
289
+ is reported as UTF-8 JSON bytes, excluded from token ratios.
290
+ These are storage layers, not disjoint semantic content classifications.
291
+ The denominator counts each complete frozen source file exactly once.
292
+ """
293
+ from cbe.records import frozen_sources, summary_and_remainder
294
+ from cbe.token_budget import count_text_tokens, count_section_tokens
295
+
296
+ context = json.loads((run_dir / "context.json").read_text(encoding="utf-8"))
297
+ source_tokens = sum(count_text_tokens(text) for text in frozen_sources(context).values())
298
+ semantic = sum(count_text_tokens(path.read_text(encoding="utf-8"))
299
+ for path in sorted((run_dir / "docs").rglob("*.md")))
300
+ catalog_text = (run_dir / "catalog.json").read_text(encoding="utf-8")
301
+ catalog = json.loads(catalog_text)
302
+ catalog_tokens = count_text_tokens(catalog_text)
303
+ body_lines = set()
304
+ for unit in catalog.get("units", {}).values():
305
+ for field in ("explain", "summary"):
306
+ body_lines.update(line.strip() for line in (unit.get(field) or "").splitlines() if line.strip())
307
+ _, remainder = summary_and_remainder(unit)
308
+ body_lines.update(line.strip() for line in remainder.splitlines() if line.strip())
309
+ for extra in unit.get("extras") or []:
310
+ if isinstance(extra, dict):
311
+ body_lines.update(line.strip() for line in
312
+ f"- {extra.get('topic')}: {str(extra.get('note', '')).strip()}".splitlines()
313
+ if line.strip())
314
+ for item in unit.get("uncertainties") or []:
315
+ body_lines.update(line.strip() for line in f"- {item}".splitlines() if line.strip())
316
+ breakdown = dict.fromkeys(("model_body", "signature_location", "heading_anchor", "relations",
317
+ "footer", "index", "node_text"), 0)
318
+ for path in sorted((run_dir / "docs").rglob("*.md")):
319
+ sections = []
320
+ in_units = False
321
+ in_footer = False
322
+ for line in path.read_text(encoding="utf-8").splitlines(keepends=True):
323
+ stripped = line.strip()
324
+ if stripped.startswith("### ") or '<a id="u-' in stripped:
325
+ in_units = True
326
+ if stripped.startswith("Look up code:"):
327
+ in_footer = True
328
+ if path.parent == run_dir / "docs":
329
+ category = "index"
330
+ elif re.match(r"\[f\d+\]: ", stripped):
331
+ category = "signature_location"
332
+ elif in_footer:
333
+ category = "footer"
334
+ elif in_units and stripped in body_lines:
335
+ category = "model_body"
336
+ elif stripped.startswith(("### ", '<a id="u-')):
337
+ category = "heading_anchor"
338
+ elif in_units and re.match(r"`.*` · \[", stripped):
339
+ category = "signature_location"
340
+ elif in_units and re.search(r": \[.*\](?:\(|\[)", stripped):
341
+ category = "relations"
342
+ else:
343
+ category = "node_text"
344
+ sections.append((category, line))
345
+ for category, count in count_section_tokens(sections).items():
346
+ breakdown[category] += count
347
+ graph = context.get("graph") or catalog.get("graph")
348
+ graph_bytes = (len(json.dumps(graph, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
349
+ if graph else 0)
350
+ if catalog.get("graph"):
351
+ catalog_tokens = count_text_tokens(json.dumps({**catalog, "graph": None}, ensure_ascii=False, indent=2) + "\n")
352
+ graph_tokens = 0
353
+ deterministic = catalog_tokens
354
+ return {"tokenizer": "o200k_base", "source_tokens": source_tokens,
355
+ "semantic_tokens": semantic, "semantic_breakdown": breakdown,
356
+ "model_body_tokens": breakdown["model_body"],
357
+ "model_body_source_ratio": breakdown["model_body"] / source_tokens if source_tokens else None,
358
+ "published_markdown_source_ratio": semantic / source_tokens if source_tokens else None,
359
+ "deterministic_tokens": deterministic,
360
+ "catalog_tokens": catalog_tokens, "frozen_graph_tokens": graph_tokens,
361
+ "frozen_graph_bytes": graph_bytes,
362
+ "semantic_source_ratio": semantic / source_tokens if source_tokens else None,
363
+ "deterministic_source_ratio": deterministic / source_tokens if source_tokens else None,
364
+ "scope": "Breakdown partitions full Markdown BPE tokens by the first byte's line: model_body "
365
+ "is published unit explain, summary, extras and uncertainties; signature_location includes "
366
+ "shared source-file reference definitions (including ones used by relations); "
367
+ "heading_anchor, relations, footer, INDEX and node_text (including labels/whitespace) "
368
+ "partition the rest. model_body/source and published_markdown/source use complete frozen files. "
369
+ "semantic: all published Markdown (node pages + INDEX, including navigation, "
370
+ "signatures and commands); deterministic: catalog.json as serialized, including "
371
+ "preserved prose copies and edge references, excluding the graph field; frozen graph "
372
+ "is excluded from token ratios (frozen_graph_tokens=0), reported separately as frozen_graph_bytes "
373
+ "using UTF-8 JSON with ensure_ascii=False and compact separators; complete "
374
+ "graph evidence is expanded only with query --edges; source: complete frozen files counted once; "
375
+ "context source chunks, host prompts, raw answers and summary.json excluded; layers are "
376
+ "storage measures, not disjoint content classes"}