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.
- cbe/__init__.py +9 -0
- cbe/__main__.py +4 -0
- cbe/aggregation.py +183 -0
- cbe/api_completion.py +410 -0
- cbe/batching.py +283 -0
- cbe/catalog_query.py +376 -0
- cbe/checks.py +390 -0
- cbe/cli.py +434 -0
- cbe/concrete_facts.py +321 -0
- cbe/data/LICENSE.tiktoken +30 -0
- cbe/data/o200k_base.tiktoken +199998 -0
- cbe/declarations.py +537 -0
- cbe/docs.py +929 -0
- cbe/generate.py +316 -0
- cbe/grammars.py +741 -0
- cbe/graph.py +857 -0
- cbe/graph_backends/__init__.py +43 -0
- cbe/graph_backends/_calls.py +262 -0
- cbe/graph_backends/_paths.py +42 -0
- cbe/graph_backends/identity.py +93 -0
- cbe/graph_backends/imports.py +256 -0
- cbe/graph_backends/joern.py +1021 -0
- cbe/graph_backends/resolution.py +288 -0
- cbe/graph_backends/toolchain.py +396 -0
- cbe/grounding.py +57 -0
- cbe/host_run.py +1752 -0
- cbe/model_settings.py +80 -0
- cbe/projection.py +305 -0
- cbe/prompts.py +316 -0
- cbe/records.py +200 -0
- cbe/skeleton.py +474 -0
- cbe/source_context.py +709 -0
- cbe/symbols.py +136 -0
- cbe/token_budget.py +68 -0
- cbe/treename.py +398 -0
- cbe/usage.py +300 -0
- cbe_install/__init__.py +11 -0
- cbe_install/cli.py +434 -0
- cbe_install/resources/agents/cbe-writer.md +16 -0
- cbe_install/resources/codebase-explorer/SKILL.md +101 -0
- cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
- cbe_install/resources/codebase-explorer/references/codex.md +37 -0
- codebase_explorer-2.2.0.dist-info/METADATA +233 -0
- codebase_explorer-2.2.0.dist-info/RECORD +47 -0
- codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
- codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
- 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"}
|