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/docs.py
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
"""Published documents and their index: node pages, INDEX, catalog, structural checks, queries.
|
|
2
|
+
|
|
3
|
+
The program writes every source range and anchor; the model writes only the
|
|
4
|
+
explanations. ``verify`` checks identity, coverage, ownership and navigation;
|
|
5
|
+
it does not judge whether an explanation is true.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
from cbe import concrete_facts
|
|
19
|
+
from cbe.catalog_query import query_catalog
|
|
20
|
+
from cbe.symbols import SemanticError, inventory_ids
|
|
21
|
+
from cbe.treename import normalize_title
|
|
22
|
+
|
|
23
|
+
# One repository file larger than this becomes its own inventory unit region set;
|
|
24
|
+
# a chunk never has to fit a model context alone because batches group many.
|
|
25
|
+
SCHEMA = "cbe-semantic/1"
|
|
26
|
+
DEPENDENCY_STYLES = {"graph_confirmed": ("graph-confirmed", "-->"),
|
|
27
|
+
"model_inferred": ("model-inferred", "-.->"),
|
|
28
|
+
"graph_only": ("graph-only", "==>")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unit_ranges(uid: str, unit: dict, inventory: dict[str, dict]) -> list[dict]:
|
|
32
|
+
"""Program-derived ``[{path, lines:[[start,end],...]}]`` from the inventory.
|
|
33
|
+
|
|
34
|
+
The model never supplies a range; the ranged ids come from the unit's own
|
|
35
|
+
inventory id and from any declared members."""
|
|
36
|
+
collected: dict[str, list[list[int]]] = {}
|
|
37
|
+
ids: list[str] = []
|
|
38
|
+
path = uid.split("::", 1)[0]
|
|
39
|
+
if "::" in uid and path in inventory:
|
|
40
|
+
ids.append(uid)
|
|
41
|
+
for member in unit.get("members") or []:
|
|
42
|
+
if isinstance(member, str) and "::" in member \
|
|
43
|
+
and member.split("::", 1)[0] in inventory and member not in ids:
|
|
44
|
+
ids.append(member)
|
|
45
|
+
if not ids:
|
|
46
|
+
return []
|
|
47
|
+
for member in ids:
|
|
48
|
+
member_path = member.split("::", 1)[0]
|
|
49
|
+
for row in inventory.get(member_path, {}).get("symbols", []):
|
|
50
|
+
if row["id"] == member:
|
|
51
|
+
for pair in row["lines"]:
|
|
52
|
+
if pair not in collected.setdefault(member_path, []):
|
|
53
|
+
collected[member_path].append(pair)
|
|
54
|
+
return [{"path": p, "lines": pairs} for p, pairs in sorted(collected.items())]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def short_name(uid: str) -> str:
|
|
58
|
+
if uid.startswith("grp:"):
|
|
59
|
+
return uid[4:]
|
|
60
|
+
_path, _, symbol = uid.partition("::")
|
|
61
|
+
return symbol or uid
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def anchor(uid: str) -> str:
|
|
65
|
+
return "u-" + hashlib.sha256(uid.encode("utf-8")).hexdigest()[:12]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def link_text(text: str) -> str:
|
|
69
|
+
"""Escape generated Markdown link labels, including literal backslashes."""
|
|
70
|
+
return re.sub(r"([\\\[\]()`*_<>])", r"\\\1", text)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def code_text(text: str) -> str:
|
|
74
|
+
"""Keep original IDs verbatim, even when they contain Markdown characters."""
|
|
75
|
+
width = max((len(run) for run in re.findall(r"`+", text)), default=0) + 1
|
|
76
|
+
fence = "`" * width
|
|
77
|
+
return f"{fence} {text} {fence}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def inline_code(text: str) -> str:
|
|
81
|
+
"""Compact code span: padded only where the text itself touches a backtick."""
|
|
82
|
+
fence = "`" * (max((len(run) for run in re.findall(r"`+", text)), default=0) + 1)
|
|
83
|
+
pad = " " if text.startswith("`") or text.endswith("`") else ""
|
|
84
|
+
return f"{fence}{pad}{text}{pad}{fence}"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _file_link(label: str, path: str, source_links: dict[str, str]) -> str:
|
|
88
|
+
reference = source_links.setdefault(path, f"f{len(source_links) + 1}")
|
|
89
|
+
return f"[{label}][{reference}]"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _relation_link(target: str, units: dict, dispositions: dict, owner: dict,
|
|
93
|
+
nodes: dict, source_root: Path,
|
|
94
|
+
name_of: Callable[[str], str], source_links: dict[str, str]) -> str:
|
|
95
|
+
resolved = target if target in units else None
|
|
96
|
+
if resolved is None:
|
|
97
|
+
entry = dispositions.get(target) or {}
|
|
98
|
+
if entry.get("how") in {"unit", "grouped"} and entry.get("unit") in units:
|
|
99
|
+
resolved = entry["unit"]
|
|
100
|
+
if resolved is not None and owner.get(resolved) in nodes:
|
|
101
|
+
return f"[{name_of(resolved)}]({owner[resolved]}.md#{anchor(resolved)})"
|
|
102
|
+
path = target.split("::", 1)[0]
|
|
103
|
+
if (source_root / path).exists():
|
|
104
|
+
return _file_link(name_of(target), path, source_links)
|
|
105
|
+
return f"[{name_of(target)}](../../catalog.json)"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_unit_block(uid: str, units: dict, owner: dict,
|
|
109
|
+
dispositions: dict, nodes: dict, source_root: Path,
|
|
110
|
+
name_of: Callable[[str], str], source_links: dict[str, str]) -> str:
|
|
111
|
+
unit = units[uid]
|
|
112
|
+
lines = [f"### `{name_of(uid)}`", ""]
|
|
113
|
+
raw_signature = unit.get("signature") or unit.get("kind") or "unit"
|
|
114
|
+
declaration = re.search(r"\b(?:async def|def|class)\s+\w", raw_signature)
|
|
115
|
+
if declaration:
|
|
116
|
+
raw_signature = raw_signature[declaration.start():]
|
|
117
|
+
signature = " ".join(raw_signature.split())
|
|
118
|
+
if len(signature) > 88:
|
|
119
|
+
signature = signature[:85] + "…"
|
|
120
|
+
location = ""
|
|
121
|
+
ranges = unit.get("ranges") or []
|
|
122
|
+
if ranges:
|
|
123
|
+
entry = unit.get("primary_range") or ranges[0]
|
|
124
|
+
start, end = entry["lines"][0]
|
|
125
|
+
location = " · " + _file_link(f"{entry['path']}:L{start}-L{end}", entry["path"], source_links)
|
|
126
|
+
lines += [f"`{signature.replace('`', '')}`{location}", ""]
|
|
127
|
+
from cbe.records import summary_and_remainder
|
|
128
|
+
|
|
129
|
+
summary, explain = summary_and_remainder(unit)
|
|
130
|
+
if summary:
|
|
131
|
+
lines += [literal_links(summary), ""]
|
|
132
|
+
if explain:
|
|
133
|
+
lines += [literal_links(explain), ""]
|
|
134
|
+
if unit.get("ungrounded"):
|
|
135
|
+
lines += [f"check: names {', '.join(unit['ungrounded'])}, not found in this unit's source", ""]
|
|
136
|
+
extras = [extra for extra in unit.get("extras") or []
|
|
137
|
+
if isinstance(extra, dict) and isinstance(extra.get("note"), str)
|
|
138
|
+
and extra["note"].strip()]
|
|
139
|
+
if extras:
|
|
140
|
+
lines += ["**Notes.**", ""]
|
|
141
|
+
lines += [f"- {extra.get('topic')}: {literal_links(extra['note'].strip())}" for extra in extras]
|
|
142
|
+
lines.append("")
|
|
143
|
+
relations = [rel for rel in unit.get("relations") or []
|
|
144
|
+
if isinstance(rel, dict) and isinstance(rel.get("target"), str)
|
|
145
|
+
and rel["target"].strip()]
|
|
146
|
+
if relations:
|
|
147
|
+
labels = {"call": "calls", "inherit": "inherits", "override": "overrides", "import": "imports",
|
|
148
|
+
"raise": "raises", "use": "uses", "contract": "contract", "group": "group"}
|
|
149
|
+
grouped: dict[str, list[str]] = {}
|
|
150
|
+
for rel in relations:
|
|
151
|
+
label = labels.get(rel.get("kind"), rel.get("kind") or "uses")
|
|
152
|
+
link = _relation_link(rel["target"], units, dispositions, owner, nodes,
|
|
153
|
+
source_root, name_of, source_links)
|
|
154
|
+
if link not in grouped.setdefault(label, []):
|
|
155
|
+
grouped[label].append(link)
|
|
156
|
+
lines += ["; ".join(f"{label}: {', '.join(links)}" for label, links in grouped.items()), ""]
|
|
157
|
+
uncertainties = [item for item in unit.get("uncertainties") or []
|
|
158
|
+
if isinstance(item, str) and item.strip()]
|
|
159
|
+
if uncertainties:
|
|
160
|
+
lines += ["**Uncertainties.**", ""]
|
|
161
|
+
lines += [f"- {literal_links(item)}" for item in uncertainties]
|
|
162
|
+
lines.append("")
|
|
163
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _graph_coverage(graph: dict | None, source_root: Path) -> list[str]:
|
|
167
|
+
"""What the graph covers, in plain lines (RUN.md)."""
|
|
168
|
+
lines: list[str] = []
|
|
169
|
+
if graph is None:
|
|
170
|
+
lines += ["No graph was provided. Relations are inferred by the model from source and may be inaccurate."]
|
|
171
|
+
else:
|
|
172
|
+
from cbe.graph import read_graph_quality, graph_state
|
|
173
|
+
quality = read_graph_quality(graph)
|
|
174
|
+
state, _ = graph_state(graph, quality=quality)
|
|
175
|
+
if state == "failed":
|
|
176
|
+
lines += ["No graph backend succeeded. Relations are inferred by the model from source."]
|
|
177
|
+
elif state == "empty":
|
|
178
|
+
lines += ["The graph is empty. No relations are graph-confirmed; relations are inferred by the model from source."]
|
|
179
|
+
elif state == "unsupported":
|
|
180
|
+
lines += ["The graph does not support this analysis. Relations are inferred by the model from source and may be inaccurate."]
|
|
181
|
+
else:
|
|
182
|
+
lines += ["Source: static analysis graph. Resolved relations are graph-confirmed; this does not guarantee semantic correctness. "
|
|
183
|
+
"External targets are outside the repository; ambiguous targets have multiple candidates. "
|
|
184
|
+
"Unresolved or unsupported relations, and languages or relation kinds without coverage, rely on model inference and may be inaccurate."]
|
|
185
|
+
by_language: dict[str, list[dict]] = {}
|
|
186
|
+
for row in quality["edge_counts"]:
|
|
187
|
+
by_language.setdefault(row["language"], []).append(row)
|
|
188
|
+
lines += ["Import and call status ratios use the edge count of each kind. "
|
|
189
|
+
"CFG and dataflow are structural edges; only their counts are shown, without status ratios."]
|
|
190
|
+
for language, rows in sorted(by_language.items()):
|
|
191
|
+
by_kind = {row["kind"]: row for row in rows}
|
|
192
|
+
relations = []
|
|
193
|
+
for kind in ("import", "call"):
|
|
194
|
+
row = by_kind.get(kind)
|
|
195
|
+
if row is None:
|
|
196
|
+
continue
|
|
197
|
+
total = row["total"]
|
|
198
|
+
proportions = ", ".join(f"{status} {count}/{total} ({count / total:.0%})"
|
|
199
|
+
for status, count in row["statuses"].items() if count) if total else "no edges"
|
|
200
|
+
relations.append(f"{language} {kind}: {proportions}")
|
|
201
|
+
structural = ", ".join(f"{kind}={by_kind[kind]['total']}"
|
|
202
|
+
for kind in ("cfg", "dataflow") if kind in by_kind)
|
|
203
|
+
if relations:
|
|
204
|
+
lines += [" · ".join(relations) + "."]
|
|
205
|
+
if structural:
|
|
206
|
+
lines += [f"{language} structural edges: {structural}."]
|
|
207
|
+
incomplete = quality["incomplete_capabilities"]
|
|
208
|
+
if incomplete:
|
|
209
|
+
lines += ["Incomplete capabilities: " + "; ".join(
|
|
210
|
+
f"{c.get('language')}/{c.get('graph')} {c.get('status')}"
|
|
211
|
+
for c in incomplete) + "."]
|
|
212
|
+
integrity = [f"dangling edges {quality['dangling_edges']['count']}",
|
|
213
|
+
f"nodes outside manifest {quality['nodes_outside_manifest']['count']}",
|
|
214
|
+
f"nodes with missing files {quality['nodes_missing_files']['count']}"
|
|
215
|
+
if quality["file_existence_checked"] else "file existence not checked"]
|
|
216
|
+
if quality["frontend_failures"]:
|
|
217
|
+
integrity.append("frontend failed; see graph diagnostics")
|
|
218
|
+
if quality["backend_completed"] is None:
|
|
219
|
+
integrity.append("backend completion unknown")
|
|
220
|
+
lines += ["Program checks: " + "; ".join(integrity) + "."]
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
INDEX_MIN_TOKENS = 2000 # the entry page may use at least this many tokens ...
|
|
225
|
+
INDEX_SHARE = 0.03 # ... or this share of the source's tokens
|
|
226
|
+
FILES_SHOWN = 3
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _rank_files(units: dict, ranges_map: dict, members: list[str]) -> list[str]:
|
|
230
|
+
"""File names of a node's subtree, the ones with most unit lines first."""
|
|
231
|
+
lines: dict[str, int] = {}
|
|
232
|
+
for uid in members:
|
|
233
|
+
for rng in ranges_map.get(uid) or units.get(uid, {}).get("ranges") or []:
|
|
234
|
+
lines[rng["path"]] = lines.get(rng["path"], 0) + sum(b - a + 1 for a, b in rng["lines"])
|
|
235
|
+
return [path for path, _ in sorted(lines.items(), key=lambda item: (-item[1], item[0]))]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _short_names(paths) -> dict[str, str]:
|
|
239
|
+
"""The shortest trailing part of each path that no other path shares (``a/b.py``, ``b.py`` ...)."""
|
|
240
|
+
split = {path: path.split("/") for path in set(paths)}
|
|
241
|
+
depth = max((len(parts) for parts in split.values()), default=1)
|
|
242
|
+
names: dict[str, str] = {}
|
|
243
|
+
for width in range(1, depth + 1):
|
|
244
|
+
count: dict[str, int] = {}
|
|
245
|
+
for parts in split.values():
|
|
246
|
+
key = "/".join(parts[-width:])
|
|
247
|
+
count[key] = count.get(key, 0) + 1
|
|
248
|
+
for path, parts in split.items():
|
|
249
|
+
key = "/".join(parts[-width:])
|
|
250
|
+
if path not in names and (count[key] == 1 or width >= len(parts)):
|
|
251
|
+
names[path] = key
|
|
252
|
+
return names
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _entry_page(*, repo: str, nodes: dict, children: dict, roots: list[str], units: dict, ranges_map: dict,
|
|
256
|
+
overview: str | None, flows: list[dict], candidates: list[list[str]], graph_state: str,
|
|
257
|
+
graph_reason: str, owner: dict, name_of: Callable[[str], str], budget: int) -> tuple[str, dict]:
|
|
258
|
+
"""INDEX.md: the module tree with one capability line per node, key flows, the three commands.
|
|
259
|
+
|
|
260
|
+
The tree is drawn to its full depth. Over the token budget, every node first shows one file instead of
|
|
261
|
+
three, then no file, and only then does the deepest level fold away (it stays on the node pages): the
|
|
262
|
+
leaf modules are what a reader looks for, a representative file is only a hint."""
|
|
263
|
+
from cbe.token_budget import count_text_tokens
|
|
264
|
+
|
|
265
|
+
members: dict[str, list[str]] = {}
|
|
266
|
+
|
|
267
|
+
def subtree(nid: str) -> list[str]:
|
|
268
|
+
if nid not in members:
|
|
269
|
+
members[nid] = list(nodes[nid].get("unit_ids") or [])
|
|
270
|
+
for kid in children.get(nid) or []:
|
|
271
|
+
members[nid] += subtree(kid)
|
|
272
|
+
return members[nid]
|
|
273
|
+
|
|
274
|
+
top = [kid for root in roots for kid in (children.get(root) or [])] or roots
|
|
275
|
+
|
|
276
|
+
def depth(nid: str) -> int:
|
|
277
|
+
return 1 + max((depth(kid) for kid in children.get(nid) or []), default=0)
|
|
278
|
+
|
|
279
|
+
levels = max((depth(nid) for nid in top), default=1)
|
|
280
|
+
ranked = {nid: _rank_files(units, ranges_map, subtree(nid)) for nid in nodes}
|
|
281
|
+
short = _short_names(path for row in ranked.values() for path in row)
|
|
282
|
+
|
|
283
|
+
def module_lines(nid: str, level: int, shown: int, files: int) -> list[str]:
|
|
284
|
+
node = nodes[nid]
|
|
285
|
+
names = [short[path] for path in ranked[nid][:files]]
|
|
286
|
+
capability = literal_links((node.get("capability") or "").strip())
|
|
287
|
+
rows = [" " * level + f"- [**{link_text(node.get('title') or nid)}**](nodes/{nid}.md)"
|
|
288
|
+
f"{' — ' + capability if capability else ''} "
|
|
289
|
+
f"({len(subtree(nid))} units{'; ' if names else ''}{', '.join(inline_code(n) for n in names)})"]
|
|
290
|
+
if level + 1 < shown:
|
|
291
|
+
for kid in children.get(nid) or []:
|
|
292
|
+
rows += module_lines(kid, level + 1, shown, files)
|
|
293
|
+
return rows
|
|
294
|
+
|
|
295
|
+
def flow_lines() -> list[str]:
|
|
296
|
+
shown, rows = flows, []
|
|
297
|
+
if not flows and candidates:
|
|
298
|
+
rows += ["Program candidates, not selected by the model (first 5):"]
|
|
299
|
+
shown = [dict(name=f"Candidate {i}", description="Extracted by the program from resolved calls.",
|
|
300
|
+
steps=path, inferred_steps=[]) for i, path in enumerate(candidates[:5], 1)]
|
|
301
|
+
elif not flows:
|
|
302
|
+
rows += ["No flows: " + (graph_reason if graph_state != "provided" else
|
|
303
|
+
"no resolved calls or no entry units with zero incoming calls") + "."]
|
|
304
|
+
for flow in shown:
|
|
305
|
+
steps = []
|
|
306
|
+
for i, uid in enumerate(flow["steps"]):
|
|
307
|
+
if i:
|
|
308
|
+
steps.append(" → (model-inferred) → " if i in flow["inferred_steps"] else " → ")
|
|
309
|
+
steps.append(inline_code(name_of(uid)) + ("" if owner.get(uid) in nodes else " (not assigned to a node)"))
|
|
310
|
+
rows.append(f"- **{link_text(flow['name'])}**: {literal_links(flow['description'])} — " + "".join(steps))
|
|
311
|
+
return rows
|
|
312
|
+
|
|
313
|
+
def page(shown: int, files: int) -> str:
|
|
314
|
+
lines = [f"# {repo}: code map", ""]
|
|
315
|
+
if (overview or "").strip():
|
|
316
|
+
lines += [literal_links(overview.strip()), ""]
|
|
317
|
+
lines += ["## Modules", ""]
|
|
318
|
+
for nid in top:
|
|
319
|
+
lines += module_lines(nid, 0, shown, files)
|
|
320
|
+
lines += ["", "## Key flows", ""] + flow_lines()
|
|
321
|
+
lines += ["", "## Read further", "",
|
|
322
|
+
'`RUN` is this run directory. Find an `ID` by name, then read it:', "",
|
|
323
|
+
'- `cbe find --run-dir "$RUN" --term "NAME"` — locate a unit, symbol or file by name',
|
|
324
|
+
'- `cbe query --run-dir "$RUN" --id "ID"` — the explanation, relations and evidence of one unit',
|
|
325
|
+
'- `cbe source --run-dir "$RUN" --id "ID" --context 2` — the frozen source, digest checked',
|
|
326
|
+
"", "More: [DEPENDENCIES.md](DEPENDENCIES.md) · [RUN.md](RUN.md)"]
|
|
327
|
+
return "\n".join(lines) + "\n"
|
|
328
|
+
|
|
329
|
+
ladder = [(levels, FILES_SHOWN), (levels, 1), (levels, 0)] + [(n, 0) for n in range(levels - 1, 0, -1)]
|
|
330
|
+
for shown, files in ladder:
|
|
331
|
+
text = page(shown, files)
|
|
332
|
+
tokens = count_text_tokens(text)
|
|
333
|
+
if tokens <= budget:
|
|
334
|
+
break
|
|
335
|
+
return text, {"budget": budget, "tokens": tokens, "over_budget": tokens > budget, "levels": levels,
|
|
336
|
+
"levels_shown": shown, "files_per_node": files}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass
|
|
340
|
+
class _RenderPrep:
|
|
341
|
+
"""Common preparation for the document writers ``render`` calls.
|
|
342
|
+
|
|
343
|
+
Holds the node tree views, the rebuilt unit records, display names and the
|
|
344
|
+
annotated relationship view, so each writer takes one object instead of
|
|
345
|
+
sharing state through module globals."""
|
|
346
|
+
run_dir: Path
|
|
347
|
+
source_root: Path
|
|
348
|
+
inventory: dict
|
|
349
|
+
dispositions: dict
|
|
350
|
+
tree: dict
|
|
351
|
+
languages: dict[str, str]
|
|
352
|
+
index_notes: list[str] | None
|
|
353
|
+
system: dict | None
|
|
354
|
+
graph: dict | None
|
|
355
|
+
context: dict | None
|
|
356
|
+
basis: dict | None
|
|
357
|
+
source_tokens: int | None
|
|
358
|
+
docs_dir: Path
|
|
359
|
+
nodes_dir: Path
|
|
360
|
+
nodes: dict
|
|
361
|
+
children: dict[str, list[str]]
|
|
362
|
+
owner: dict[str, str]
|
|
363
|
+
units: dict
|
|
364
|
+
symbol_records: dict
|
|
365
|
+
sources: dict[str, str]
|
|
366
|
+
ranges_map: dict
|
|
367
|
+
edge_table: dict
|
|
368
|
+
names: dict
|
|
369
|
+
relationships: dict
|
|
370
|
+
labels: dict[str, str]
|
|
371
|
+
edges_by_from: dict[str, list[dict]]
|
|
372
|
+
edges_by_to: dict[str, list[dict]]
|
|
373
|
+
_subtree_sizes: dict[str, int] = field(default_factory=dict)
|
|
374
|
+
|
|
375
|
+
def subtree_units(self, nid: str) -> int:
|
|
376
|
+
if nid not in self._subtree_sizes:
|
|
377
|
+
self._subtree_sizes[nid] = len(self.nodes[nid].get("unit_ids") or []) \
|
|
378
|
+
+ sum(self.subtree_units(k) for k in self.children[nid])
|
|
379
|
+
return self._subtree_sizes[nid]
|
|
380
|
+
|
|
381
|
+
def name_of(self, uid: str) -> str:
|
|
382
|
+
return self.names.get(uid, short_name(uid))
|
|
383
|
+
|
|
384
|
+
def dependency_line(self, edge, target, prefix=""):
|
|
385
|
+
return (f"- [{link_text(self.nodes[target].get('title') or target)}]({prefix}{target}.md) · "
|
|
386
|
+
+ (f"containment · → {edge['count']} / ← {edge['reverse_count']} edge(s) "
|
|
387
|
+
f"(ancestor direct units and descendant subtree; → follows {code_text(edge['from'])} → {code_text(edge['to'])})"
|
|
388
|
+
if edge["source"] == "containment" else
|
|
389
|
+
self.labels[edge["source"]]
|
|
390
|
+
+ "".join(f" · {text}" for text in (
|
|
391
|
+
f"{edge['count']} edge(s)" if edge["count"] else "",
|
|
392
|
+
f"{edge.get('inferred_count', 0)} inferred relation(s)" if edge.get("inferred_count") else "") if text))
|
|
393
|
+
+ (" · " + literal_links(edge["description"]) if edge["description"] else ""))
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _prepare_render(*, run_dir: Path, source_root: Path, inventory: dict, units: dict,
|
|
397
|
+
dispositions: dict, tree: dict, languages: dict[str, str],
|
|
398
|
+
index_notes: list[str] | None = None, system: dict | None = None,
|
|
399
|
+
graph: dict | None = None, context: dict | None = None, basis: dict | None = None,
|
|
400
|
+
source_tokens: int | None = None) -> _RenderPrep:
|
|
401
|
+
"""Build the tree views, records, display names and relations every writer needs."""
|
|
402
|
+
docs_dir = run_dir / "docs"
|
|
403
|
+
nodes_dir = docs_dir / "nodes"
|
|
404
|
+
nodes_dir.mkdir(parents=True, exist_ok=True)
|
|
405
|
+
for stale in docs_dir.glob("nodes/*.md"):
|
|
406
|
+
stale.unlink()
|
|
407
|
+
|
|
408
|
+
nodes = {node["id"]: dict(node) for node in tree.get("nodes") or []}
|
|
409
|
+
children: dict[str, list[str]] = {nid: [] for nid in nodes}
|
|
410
|
+
owner: dict[str, str] = {}
|
|
411
|
+
for nid, node in nodes.items():
|
|
412
|
+
if node.get("parent") in nodes:
|
|
413
|
+
children[node["parent"]].append(nid)
|
|
414
|
+
for uid in node.get("unit_ids") or []:
|
|
415
|
+
owner[uid] = nid
|
|
416
|
+
|
|
417
|
+
anchors: dict[str, str] = {}
|
|
418
|
+
for uid in sorted(units):
|
|
419
|
+
key = anchor(uid)
|
|
420
|
+
if key in anchors:
|
|
421
|
+
raise SemanticError(f"anchor collision: {anchors[key]!r} and {uid!r}")
|
|
422
|
+
anchors[key] = uid
|
|
423
|
+
ranges_map = {uid: unit_ranges(uid, unit, inventory) for uid, unit in units.items()}
|
|
424
|
+
from cbe.records import build_records, frozen_sources
|
|
425
|
+
|
|
426
|
+
if context is None and (run_dir / "context.json").is_file():
|
|
427
|
+
context = json.loads((run_dir / "context.json").read_text(encoding="utf-8"))
|
|
428
|
+
sources = (frozen_sources(context, list(inventory)) if context is not None else
|
|
429
|
+
{path: (source_root / path).read_text(encoding="utf-8")
|
|
430
|
+
for path in inventory if (source_root / path).is_file()})
|
|
431
|
+
edge_table = {}
|
|
432
|
+
units, symbol_records = build_records(units, dispositions, inventory, ranges_map,
|
|
433
|
+
sources, languages, context, graph, edge_table)
|
|
434
|
+
ranges_map = {uid: record["ranges"] for uid, record in units.items()}
|
|
435
|
+
names = {}
|
|
436
|
+
taken: set[str] = set()
|
|
437
|
+
for uid in sorted(set(units) | set(dispositions)):
|
|
438
|
+
symbol = symbol_records.get(uid, {})
|
|
439
|
+
base = symbol.get("name") or short_name(uid)
|
|
440
|
+
parts = uid.split("::")
|
|
441
|
+
if symbol.get("kind") == "method" and len(parts) > 2 and "." in parts[2]:
|
|
442
|
+
base = parts[2]
|
|
443
|
+
if "::module_residual::" in uid or symbol.get("kind") == "module_residual":
|
|
444
|
+
spans = symbol["ranges"][0]["lines"]
|
|
445
|
+
base = f"{Path(symbol['file']).name} module code (L{spans[0][0]}–L{spans[-1][1]})"
|
|
446
|
+
if base in taken:
|
|
447
|
+
base = f"{uid.split('::', 1)[0]}:{base}"
|
|
448
|
+
name = base
|
|
449
|
+
suffix = 2
|
|
450
|
+
while name in taken:
|
|
451
|
+
name = f"{base} ({suffix})"
|
|
452
|
+
suffix += 1
|
|
453
|
+
taken.add(name)
|
|
454
|
+
names[uid] = name
|
|
455
|
+
from cbe import aggregation
|
|
456
|
+
unit_edges = aggregation.unit_edges(units, edge_table)
|
|
457
|
+
relationships = aggregation.annotate_relationships(
|
|
458
|
+
aggregation.relationship_view(tree, unit_edges, graph), system or {}, unit_edges)
|
|
459
|
+
labels = {source: style[0] for source, style in DEPENDENCY_STYLES.items()}
|
|
460
|
+
labels["within_containment"] = "within containment"
|
|
461
|
+
edges_by_from: dict[str, list[dict]] = {nid: [] for nid in nodes}
|
|
462
|
+
edges_by_to: dict[str, list[dict]] = {nid: [] for nid in nodes}
|
|
463
|
+
for edge in relationships["dependencies"]:
|
|
464
|
+
edges_by_from[edge["from"]].append(edge)
|
|
465
|
+
edges_by_to[edge["to"]].append(edge)
|
|
466
|
+
return _RenderPrep(run_dir=run_dir, source_root=source_root, inventory=inventory,
|
|
467
|
+
dispositions=dispositions, tree=tree, languages=languages,
|
|
468
|
+
index_notes=index_notes, system=system, graph=graph, context=context, basis=basis,
|
|
469
|
+
source_tokens=source_tokens, docs_dir=docs_dir, nodes_dir=nodes_dir,
|
|
470
|
+
nodes=nodes, children=children, owner=owner, units=units,
|
|
471
|
+
symbol_records=symbol_records, sources=sources, ranges_map=ranges_map,
|
|
472
|
+
edge_table=edge_table, names=names, relationships=relationships,
|
|
473
|
+
labels=labels, edges_by_from=edges_by_from, edges_by_to=edges_by_to)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _write_node_pages(prep: _RenderPrep) -> None:
|
|
477
|
+
"""One Markdown page per semantic node under ``docs/nodes``."""
|
|
478
|
+
for nid, node in prep.nodes.items():
|
|
479
|
+
source_links: dict[str, str] = {}
|
|
480
|
+
lines = [f"# {node.get('title') or nid}", ""]
|
|
481
|
+
if (node.get("capability") or "").strip():
|
|
482
|
+
lines += [f"**Capability.** {literal_links(node['capability'].strip())}", ""]
|
|
483
|
+
if (node.get("summary") or "").strip():
|
|
484
|
+
lines += [literal_links(node['summary'].strip()), ""]
|
|
485
|
+
if prep.children.get(nid):
|
|
486
|
+
lines += ["**Children.**", ""]
|
|
487
|
+
for kid in prep.children[nid]:
|
|
488
|
+
kid_node = prep.nodes[kid]
|
|
489
|
+
lines.append(f"- [{link_text(kid_node.get('title') or kid)}]({kid}.md) `{kid}` · "
|
|
490
|
+
f"{prep.subtree_units(kid)} unit(s)")
|
|
491
|
+
lines.append("")
|
|
492
|
+
if prep.edges_by_from.get(nid):
|
|
493
|
+
lines += ["**Depends on.**", ""]
|
|
494
|
+
for edge in prep.edges_by_from[nid]:
|
|
495
|
+
lines.append(prep.dependency_line(edge, edge["to"]))
|
|
496
|
+
lines.append("")
|
|
497
|
+
if prep.edges_by_to.get(nid):
|
|
498
|
+
lines += ["**Depended on by.**", ""]
|
|
499
|
+
lines += [prep.dependency_line(edge, edge["from"]) for edge in prep.edges_by_to[nid]]
|
|
500
|
+
lines.append("")
|
|
501
|
+
parent = node.get("parent")
|
|
502
|
+
if parent in prep.nodes:
|
|
503
|
+
lines += [f"Parent: [{link_text(prep.nodes[parent].get('title') or parent)}]({parent}.md)", ""]
|
|
504
|
+
own = node.get("unit_ids") or []
|
|
505
|
+
if own:
|
|
506
|
+
lines += [f"## Units ({len(own)})", ""]
|
|
507
|
+
for uid in own:
|
|
508
|
+
if uid not in prep.units:
|
|
509
|
+
continue
|
|
510
|
+
block = _build_unit_block(uid, prep.units, prep.owner, prep.dispositions,
|
|
511
|
+
prep.nodes, prep.source_root, prep.name_of, source_links)
|
|
512
|
+
lines += [f'<a id="{anchor(uid)}"></a>'] + block.splitlines() + [""]
|
|
513
|
+
# The commands are written once, in INDEX.md; a page reached directly still points there.
|
|
514
|
+
lines += ["Look up code: the commands are under \"Read further\" in [INDEX.md](../INDEX.md); "
|
|
515
|
+
"`cbe query` and `cbe source` take a unit title or anchor from this page.", ""]
|
|
516
|
+
lines += [f"[{ref}]: {os.path.relpath(prep.source_root / path, prep.nodes_dir)}"
|
|
517
|
+
for path, ref in source_links.items()]
|
|
518
|
+
(prep.nodes_dir / f"{nid}.md").write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _write_index(prep: _RenderPrep) -> dict:
|
|
522
|
+
"""INDEX.md, the module tree entry page (see ``_entry_page``)."""
|
|
523
|
+
roots = [nid for nid, node in prep.nodes.items() if not node.get("parent")]
|
|
524
|
+
index_text, index_meta = _entry_page(
|
|
525
|
+
repo=prep.source_root.name, nodes=prep.nodes, children=prep.children, roots=roots,
|
|
526
|
+
units=prep.units, ranges_map=prep.ranges_map,
|
|
527
|
+
overview=(prep.system or {}).get("overview"), flows=prep.relationships["key_flows"],
|
|
528
|
+
candidates=prep.relationships["candidate_flows"], graph_state=prep.relationships["graph_state"],
|
|
529
|
+
graph_reason=prep.relationships["graph_reason"], owner=prep.owner, name_of=prep.name_of,
|
|
530
|
+
budget=max(INDEX_MIN_TOKENS, int(INDEX_SHARE * (prep.source_tokens or 0))))
|
|
531
|
+
(prep.docs_dir / "INDEX.md").write_text(index_text, encoding="utf-8")
|
|
532
|
+
return index_meta
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _write_dependencies(prep: _RenderPrep) -> None:
|
|
536
|
+
"""DEPENDENCIES.md: the cross-node dependency list with a Mermaid diagram."""
|
|
537
|
+
relationships = prep.relationships
|
|
538
|
+
nodes = prep.nodes
|
|
539
|
+
dependency_line = prep.dependency_line
|
|
540
|
+
dep_lines = ["# Dependencies", "",
|
|
541
|
+
"Arrows point from a user to its dependency. Containment is structural, not a dependency. "
|
|
542
|
+
"Graph-confirmed pairs appear in both the tree and resolved call/import edges. "
|
|
543
|
+
"Model-inferred pairs appear only in the tree. Graph-only pairs appear only in the graph.",
|
|
544
|
+
"Graph-confirmed and model-inferred pairs count edges between two subtrees. "
|
|
545
|
+
"Graph-only pairs count edges between direct node owners. "
|
|
546
|
+
"Containment rows show resolved edges between ancestor direct units and a descendant subtree in both directions. "
|
|
547
|
+
"Within containment rows count direct owner pairs separately. Neither kind of containment row appears in Mermaid or the three dependency totals."]
|
|
548
|
+
if relationships["graph_state"] != "provided":
|
|
549
|
+
dep_lines += ["No usable graph evidence; only model-inferred dependencies are listed: " + relationships["graph_reason"] + "."]
|
|
550
|
+
degree = {}
|
|
551
|
+
for edge in relationships["dependencies"]:
|
|
552
|
+
if edge.get("containment"):
|
|
553
|
+
continue
|
|
554
|
+
for endpoint in (edge["from"], edge["to"]):
|
|
555
|
+
degree[endpoint] = degree.get(endpoint, 0) + 1
|
|
556
|
+
selected = sorted(degree, key=lambda nid: (-degree[nid], nid))[:15]
|
|
557
|
+
aliases = {nid: f"n{i}" for i, nid in enumerate(selected)}
|
|
558
|
+
if selected:
|
|
559
|
+
# Numeric Mermaid entity escapes protect quotes, brackets, markup and
|
|
560
|
+
# fences; only safe program-generated aliases become Mermaid ids.
|
|
561
|
+
def mermaid_label(text):
|
|
562
|
+
return "".join(c if c.isalnum() or c in " _-" else f"#{ord(c)};" for c in text[:100])
|
|
563
|
+
dep_lines += ["", "```mermaid", "flowchart LR"]
|
|
564
|
+
dep_lines += [f' {aliases[nid]}["{mermaid_label(nodes[nid].get("title") or nid)}"]'
|
|
565
|
+
for nid in selected]
|
|
566
|
+
for edge in relationships["dependencies"]:
|
|
567
|
+
if not edge.get("containment") and edge["from"] in aliases and edge["to"] in aliases:
|
|
568
|
+
arrow = DEPENDENCY_STYLES[edge["source"]][1]
|
|
569
|
+
dep_lines.append(f" {aliases[edge['from']]} {arrow} {aliases[edge['to']]}")
|
|
570
|
+
dep_lines += ["```", "", f"Selected {len(selected)} nodes by connection count. Legend: "
|
|
571
|
+
+ "; ".join(f"`{arrow}` {label}" for label, arrow in DEPENDENCY_STYLES.values()) + "."]
|
|
572
|
+
else:
|
|
573
|
+
dep_lines += ["No node dependencies."]
|
|
574
|
+
dep_lines += ["", "## Dependency list", ""]
|
|
575
|
+
for edge in relationships["dependencies"]:
|
|
576
|
+
if edge["source"] == "within_containment":
|
|
577
|
+
continue
|
|
578
|
+
dep_lines += [f"- [{link_text(nodes[edge['from']].get('title') or edge['from'])}]"
|
|
579
|
+
f"(nodes/{edge['from']}.md) → " + dependency_line(edge, edge["to"], "nodes/")[2:]]
|
|
580
|
+
dep_lines += ["", f"Graph-only pairs are ranked by edge count; {relationships['graph_only_omitted']} omitted.",
|
|
581
|
+
"", "### Within containment", ""]
|
|
582
|
+
for edge in relationships["dependencies"]:
|
|
583
|
+
if edge["source"] == "within_containment":
|
|
584
|
+
dep_lines += [f"- [{link_text(nodes[edge['from']].get('title') or edge['from'])}]"
|
|
585
|
+
f"(nodes/{edge['from']}.md) → " + dependency_line(edge, edge["to"], "nodes/")[2:]]
|
|
586
|
+
dep_lines += ["", f"Within containment: {relationships['within_containment_count']} pairs, ranked by edge count; "
|
|
587
|
+
f"{relationships['within_containment_omitted']} omitted. Each graph list keeps at most 40 pairs independently."]
|
|
588
|
+
if not (prep.system or {}).get("dependency_descriptions"):
|
|
589
|
+
dep_lines += ["", "The system response provided no key dependency descriptions."]
|
|
590
|
+
(prep.docs_dir / "DEPENDENCIES.md").write_text("\n".join(dep_lines) + "\n", encoding="utf-8")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _basis_lines(basis: dict) -> list[str]:
|
|
594
|
+
lines = [f"- CBE version: `{basis['cbe_version']}`",
|
|
595
|
+
"- Models: " + (", ".join(f"`{model}`" for model in basis["models"]) or "none recorded"),
|
|
596
|
+
f"- Source: {basis['source']['files']} files · SHA-256 `{basis['source']['sha256']}`"]
|
|
597
|
+
graph = basis["graph"]
|
|
598
|
+
if graph is None:
|
|
599
|
+
return lines + ["- Graph: none"]
|
|
600
|
+
backend = graph.get("backend") or {}
|
|
601
|
+
edges = ", ".join(f"{kind}={count}" for kind, count in graph["edge_counts"].items()) or "none"
|
|
602
|
+
replaced = f"yes, latest {graph['updated_at']}" if graph["updated"] else "no"
|
|
603
|
+
tools = ", ".join(f"`{part.get('name')}` {part.get('version') or 'version unknown'}"
|
|
604
|
+
for part in backend.get("components") or [backend]) or "unknown"
|
|
605
|
+
return lines + [f"- Graph: SHA-256 `{graph['digest']}` · built by {tools} · edges {edges} · replaced {replaced}"]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _write_run(prep: _RenderPrep) -> None:
|
|
609
|
+
"""RUN.md: where this run's facts come from and what was and was not checked."""
|
|
610
|
+
run_lines = ["# Run notes", "", "Where this run's facts come from and what was and was not checked.", ""]
|
|
611
|
+
if prep.basis is not None:
|
|
612
|
+
run_lines += ["## Generation basis", "", *_basis_lines(prep.basis), ""]
|
|
613
|
+
run_lines += ["## Reading stats", "",
|
|
614
|
+
f"- units: {len(prep.units)} · semantic nodes: {len(prep.nodes)} · "
|
|
615
|
+
f"dispositioned inventory ids: {len(prep.dispositions)}",
|
|
616
|
+
"- source ranges are program-derived from the inventory, never model-written",
|
|
617
|
+
"", "## Graph coverage", ""] + _graph_coverage(prep.graph, prep.source_root)
|
|
618
|
+
if prep.index_notes:
|
|
619
|
+
run_lines += ["", "## Generation notes", ""] + [f"- {item}" for item in prep.index_notes]
|
|
620
|
+
(prep.docs_dir / "RUN.md").write_text("\n".join(run_lines) + "\n", encoding="utf-8")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _write_catalog(prep: _RenderPrep) -> dict:
|
|
624
|
+
"""``catalog.json``: semantic index plus the per-id ``symbols`` and per-file ``files`` maps."""
|
|
625
|
+
units, owner, nodes = prep.units, prep.owner, prep.nodes
|
|
626
|
+
symbol_index: dict[str, dict] = {}
|
|
627
|
+
for uid in sorted(units):
|
|
628
|
+
node = owner.get(uid)
|
|
629
|
+
symbol_index[uid] = {"how": "unit", "unit": uid, "node": node,
|
|
630
|
+
"page": f"nodes/{node}.md" if node in nodes else None,
|
|
631
|
+
"anchor": f"#{anchor(uid)}"}
|
|
632
|
+
for key in sorted(prep.dispositions):
|
|
633
|
+
if key in symbol_index:
|
|
634
|
+
continue
|
|
635
|
+
entry = prep.dispositions[key]
|
|
636
|
+
target = entry.get("unit")
|
|
637
|
+
if entry.get("how") in {"unit", "grouped"} and target in units:
|
|
638
|
+
node = owner.get(target)
|
|
639
|
+
symbol_index[key] = {"how": entry.get("how"), "unit": target, "node": node,
|
|
640
|
+
"page": f"nodes/{node}.md" if node in nodes else None,
|
|
641
|
+
"anchor": f"#{anchor(target)}"}
|
|
642
|
+
else:
|
|
643
|
+
symbol_index[key] = {"how": entry.get("how", "index_only"), "unit": None,
|
|
644
|
+
"node": None, "page": None, "anchor": None,
|
|
645
|
+
"reason": entry.get("reason", "")}
|
|
646
|
+
|
|
647
|
+
catalog_symbol_rows: dict[str, dict] = {}
|
|
648
|
+
catalog_file_rows: dict[str, dict] = {}
|
|
649
|
+
for path, entry in prep.inventory.items():
|
|
650
|
+
facts = concrete_facts.file_facts(path, prep.sources.get(path, "")) \
|
|
651
|
+
if path.endswith(".py") else None
|
|
652
|
+
catalog_file_rows[path] = {"tokens": entry.get("tokens", 0),
|
|
653
|
+
"language": prep.languages.get(path, "unknown"),
|
|
654
|
+
"known_empty": bool(entry.get("known_empty")),
|
|
655
|
+
"ranges": [{"path": path, "lines": [[1, max(1, len(prep.sources.get(path, '').splitlines()))]]}],
|
|
656
|
+
"signatures": [{"id": sid, "signature": row["signature"]}
|
|
657
|
+
for sid, row in prep.symbol_records.items() if row["file"] == path]}
|
|
658
|
+
if facts and facts.get("file"):
|
|
659
|
+
catalog_file_rows[path]["facts"] = facts["file"]
|
|
660
|
+
for symbol in entry["symbols"]:
|
|
661
|
+
record: dict[str, Any] = {**prep.symbol_records[symbol["id"]], "file": path, "name": symbol["name"],
|
|
662
|
+
**symbol_index.get(symbol["id"], {"node": None, "page": None})}
|
|
663
|
+
parts = symbol["id"].split("::")
|
|
664
|
+
fact_name = parts[2] if symbol.get("kind") == "method" and len(parts) > 2 else symbol["name"]
|
|
665
|
+
if facts and facts["symbols"].get(fact_name):
|
|
666
|
+
record["facts"] = facts["symbols"][fact_name]
|
|
667
|
+
catalog_symbol_rows[symbol["id"]] = record
|
|
668
|
+
|
|
669
|
+
relationships = prep.relationships
|
|
670
|
+
catalog = {
|
|
671
|
+
"schema": SCHEMA, "project": "Codebase Explorer", "version": "",
|
|
672
|
+
"units": {uid: {**units[uid], "node": owner.get(uid),
|
|
673
|
+
"page": f"nodes/{owner[uid]}.md" if owner.get(uid) in nodes else None,
|
|
674
|
+
"anchor": f"#{anchor(uid)}"} for uid in sorted(units)},
|
|
675
|
+
"edges": prep.edge_table,
|
|
676
|
+
"graph": None if (prep.context or {}).get("graph") is not None else prep.graph,
|
|
677
|
+
"graph_ref": "context.json" if (prep.context or {}).get("graph") is not None else None,
|
|
678
|
+
"nodes": {nid: {"parent": node.get("parent"), "children": prep.children.get(nid) or [],
|
|
679
|
+
"unit_ids": node.get("unit_ids") or [], "title": node.get("title"),
|
|
680
|
+
"capability": node.get("capability"), "summary": node.get("summary")}
|
|
681
|
+
for nid, node in sorted(nodes.items())},
|
|
682
|
+
"depends_on": prep.tree.get("depends_on") or [],
|
|
683
|
+
"node_dependencies": relationships["dependencies"],
|
|
684
|
+
"relationships": {k: v for k, v in relationships.items() if k not in {"dependencies", "key_flows"}},
|
|
685
|
+
"key_flows": relationships["key_flows"],
|
|
686
|
+
"dispositions": prep.dispositions,
|
|
687
|
+
"symbol_index": symbol_index,
|
|
688
|
+
"inventory": prep.inventory,
|
|
689
|
+
"short_names": prep.names,
|
|
690
|
+
"symbols": catalog_symbol_rows,
|
|
691
|
+
"files": catalog_file_rows,
|
|
692
|
+
}
|
|
693
|
+
(prep.run_dir / "catalog.json").write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n",
|
|
694
|
+
encoding="utf-8")
|
|
695
|
+
return symbol_index
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def render(*, run_dir: Path, source_root: Path, inventory: dict, units: dict,
|
|
699
|
+
dispositions: dict, tree: dict, languages: dict[str, str],
|
|
700
|
+
index_notes: list[str] | None = None, system: dict | None = None,
|
|
701
|
+
graph: dict | None = None, context: dict | None = None, basis: dict | None = None,
|
|
702
|
+
source_tokens: int | None = None) -> dict:
|
|
703
|
+
"""Render node pages, INDEX.md, DEPENDENCIES.md, RUN.md and a catalog under ``run_dir/docs``.
|
|
704
|
+
|
|
705
|
+
``languages`` maps each path to the language frozen in the source context,
|
|
706
|
+
so the catalog agrees with the plan even when it remapped a suffix.
|
|
707
|
+
|
|
708
|
+
Returns metadata the caller stores in run state for verification."""
|
|
709
|
+
prep = _prepare_render(run_dir=run_dir, source_root=source_root, inventory=inventory,
|
|
710
|
+
units=units, dispositions=dispositions, tree=tree, languages=languages,
|
|
711
|
+
index_notes=index_notes, system=system, graph=graph, context=context, basis=basis,
|
|
712
|
+
source_tokens=source_tokens)
|
|
713
|
+
_write_node_pages(prep)
|
|
714
|
+
index_meta = _write_index(prep)
|
|
715
|
+
_write_dependencies(prep)
|
|
716
|
+
_write_run(prep)
|
|
717
|
+
symbol_index = _write_catalog(prep)
|
|
718
|
+
return {"units_rendered": len(prep.units), "nodes": len(prep.nodes), "owner": prep.owner,
|
|
719
|
+
"relationship_counts": prep.relationships["counts"],
|
|
720
|
+
"within_containment_count": prep.relationships["within_containment_count"],
|
|
721
|
+
"key_flows": len(prep.relationships["key_flows"]),
|
|
722
|
+
"candidate_flows": len(prep.relationships["candidate_flows"]),
|
|
723
|
+
"ranges_map": prep.ranges_map, "symbol_index": symbol_index,
|
|
724
|
+
"index": {**index_meta, "path": str(prep.docs_dir / "INDEX.md")}}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _map_prose(text: str, prose: Callable[[str], str], code: Callable[[str], str]) -> str:
|
|
728
|
+
"""Map prose and code separately, respecting fences and paired code spans."""
|
|
729
|
+
paragraphs = []
|
|
730
|
+
pending = []
|
|
731
|
+
fence = None
|
|
732
|
+
|
|
733
|
+
def flush():
|
|
734
|
+
paragraph = "".join(pending)
|
|
735
|
+
parts, start = [], 0
|
|
736
|
+
for match in re.finditer(r"(?<!`)(`+)(?!`)(.*?)(?<!`)\1(?!`)", paragraph, re.DOTALL):
|
|
737
|
+
parts.extend((prose(paragraph[start:match.start()]), code(match[0])))
|
|
738
|
+
start = match.end()
|
|
739
|
+
parts.append(prose(paragraph[start:]))
|
|
740
|
+
paragraphs.append("".join(parts))
|
|
741
|
+
pending.clear()
|
|
742
|
+
|
|
743
|
+
for line in text.splitlines(keepends=True):
|
|
744
|
+
marker = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line)
|
|
745
|
+
if fence:
|
|
746
|
+
if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip():
|
|
747
|
+
fence = None
|
|
748
|
+
paragraphs.append(code(line))
|
|
749
|
+
continue
|
|
750
|
+
if marker and (marker[1][0] != "`" or "`" not in marker[2]):
|
|
751
|
+
flush()
|
|
752
|
+
fence = marker[1]
|
|
753
|
+
paragraphs.append(code(line))
|
|
754
|
+
elif not line.strip():
|
|
755
|
+
flush()
|
|
756
|
+
paragraphs.append(line)
|
|
757
|
+
elif line.startswith((" ", "\t")) and not pending:
|
|
758
|
+
paragraphs.append(code(line))
|
|
759
|
+
else:
|
|
760
|
+
pending.append(line)
|
|
761
|
+
flush()
|
|
762
|
+
return "".join(paragraphs)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def literal_links(text: str) -> str:
|
|
766
|
+
"""Keep model-written inline links as visible text; code stays verbatim.
|
|
767
|
+
|
|
768
|
+
Source and navigation links are generated separately by the renderer.
|
|
769
|
+
Escaping the opening parenthesis prevents accidental Markdown links such
|
|
770
|
+
as ``environ['werkzeug.debug.preserve_context'](ctx)``.
|
|
771
|
+
"""
|
|
772
|
+
return _map_prose(text, lambda part: part.replace("](", r"]\("), lambda part: part)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def prose_for_links(text: str) -> str:
|
|
776
|
+
"""Hide code blocks and paired code spans within each prose paragraph."""
|
|
777
|
+
return _map_prose(text, lambda part: part, lambda part: "")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _add_check(checks: list[dict], name: str, ok: bool, detail: Any = None) -> None:
|
|
781
|
+
checks.append({"check": name, "ok": bool(ok),
|
|
782
|
+
**({"detail": detail} if detail is not None else {})})
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _verify_catalog(checks: list[dict], run_dir: Path, inventory: dict, units: dict,
|
|
786
|
+
dispositions: dict, owner: dict, nodes: dict, inv_ids: set,
|
|
787
|
+
page_texts: dict) -> None:
|
|
788
|
+
"""The catalog-related checks: every file listed, every navigation target reachable."""
|
|
789
|
+
catalog_path = run_dir / "catalog.json"
|
|
790
|
+
if catalog_path.is_file():
|
|
791
|
+
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
792
|
+
listed = set(catalog.get("files") or {})
|
|
793
|
+
missing = sorted(set(inventory) - listed)
|
|
794
|
+
known_empty_missing = sorted(path for path, entry in inventory.items()
|
|
795
|
+
if entry.get("known_empty")
|
|
796
|
+
and not (catalog.get("files") or {}).get(path, {}).get("known_empty"))
|
|
797
|
+
_add_check(checks, "catalog_lists_every_file", not missing and not known_empty_missing,
|
|
798
|
+
{"inventory_files": len(inventory), "missing": missing[:12],
|
|
799
|
+
"known_empty_not_marked": known_empty_missing[:12]})
|
|
800
|
+
# Compare the actual CLI view with accepted ownership and rendered
|
|
801
|
+
# anchors, not just the number of catalog entries.
|
|
802
|
+
searchable = query_catalog(run_dir)["symbols"]
|
|
803
|
+
navigation_errors = []
|
|
804
|
+
for sid in sorted(inv_ids | set(units)):
|
|
805
|
+
record = searchable.get(sid)
|
|
806
|
+
disposition = dispositions.get(sid) or {}
|
|
807
|
+
target = sid if sid in units else disposition.get("unit")
|
|
808
|
+
if sid not in units and disposition.get("how") == "index_only":
|
|
809
|
+
if (record is None or record.get("how") != "index_only"
|
|
810
|
+
or record.get("page") is not None or record.get("anchor") is not None
|
|
811
|
+
or record.get("reason") != disposition.get("reason", "")):
|
|
812
|
+
navigation_errors.append(f"{sid}: index-only disposition lost")
|
|
813
|
+
continue
|
|
814
|
+
node = owner.get(target)
|
|
815
|
+
page = f"nodes/{node}.md"
|
|
816
|
+
fragment = f"#{anchor(target)}" if target else None
|
|
817
|
+
if (target not in units or node not in nodes
|
|
818
|
+
or not record or record.get("unit") != target or record.get("node") != node
|
|
819
|
+
or record.get("page") != page or record.get("anchor") != fragment
|
|
820
|
+
or record.get("how") != ("unit" if sid in units else disposition.get("how"))
|
|
821
|
+
or f'id="{fragment[1:]}"' not in page_texts.get(page, "")):
|
|
822
|
+
navigation_errors.append(f"{sid}: primary page or anchor is missing/wrong")
|
|
823
|
+
_add_check(checks, "catalog_navigation_resolves", not navigation_errors,
|
|
824
|
+
{"checked": len(inv_ids | set(units)), "broken": navigation_errors[:12]})
|
|
825
|
+
else:
|
|
826
|
+
_add_check(checks, "catalog_lists_every_file", False, "catalog.json is missing")
|
|
827
|
+
_add_check(checks, "catalog_navigation_resolves", False, "catalog.json is missing")
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def verify(run_dir: Path, inventory: dict, units: dict, dispositions: dict,
|
|
831
|
+
tree: dict) -> list[dict]:
|
|
832
|
+
"""Deterministic structural/coverage checks for the semantic artifact.
|
|
833
|
+
|
|
834
|
+
These check identity, coverage and ownership. They do NOT judge prose
|
|
835
|
+
quality or whether an explanation is true; that stays with an independent
|
|
836
|
+
review of the source, and a run that passes here is not thereby
|
|
837
|
+
semantically verified."""
|
|
838
|
+
checks: list[dict] = []
|
|
839
|
+
|
|
840
|
+
inv_ids = set(inventory_ids(inventory))
|
|
841
|
+
missing = sorted(inv_ids - set(dispositions))
|
|
842
|
+
broken = [key for key, entry in dispositions.items()
|
|
843
|
+
if (entry or {}).get("how") in {"unit", "grouped"}
|
|
844
|
+
and entry.get("unit") not in units]
|
|
845
|
+
_add_check(checks, "semantic_dispositions_cover_inventory", not missing and not broken,
|
|
846
|
+
{"inventory_ids": len(inv_ids), "missing": missing[:12], "broken": broken[:12]})
|
|
847
|
+
|
|
848
|
+
nodes = {node["id"]: node for node in tree.get("nodes") or []}
|
|
849
|
+
owner: dict[str, str] = {}
|
|
850
|
+
dupes = []
|
|
851
|
+
for nid, node in nodes.items():
|
|
852
|
+
for uid in node.get("unit_ids") or []:
|
|
853
|
+
if uid in owner:
|
|
854
|
+
dupes.append(f"{uid}:{owner[uid]}+{nid}")
|
|
855
|
+
owner[uid] = nid
|
|
856
|
+
_add_check(checks, "unique_primary_ownership", not dupes and set(units) <= set(owner),
|
|
857
|
+
{"units": len(units), "assigned": len(owner), "dupes": dupes[:12],
|
|
858
|
+
"unassigned": sorted(set(units) - set(owner))[:12]})
|
|
859
|
+
|
|
860
|
+
roots = [nid for nid, node in nodes.items() if not node.get("parent")]
|
|
861
|
+
children: dict[str, list[str]] = {nid: [] for nid in nodes}
|
|
862
|
+
for nid, node in nodes.items():
|
|
863
|
+
if node.get("parent") in nodes:
|
|
864
|
+
children[node["parent"]].append(nid)
|
|
865
|
+
cycle = False
|
|
866
|
+
seen: set[str] = set()
|
|
867
|
+
stack = roots[:1]
|
|
868
|
+
while stack:
|
|
869
|
+
nid = stack.pop()
|
|
870
|
+
if nid in seen:
|
|
871
|
+
cycle = True
|
|
872
|
+
continue
|
|
873
|
+
seen.add(nid)
|
|
874
|
+
stack.extend(children.get(nid, []))
|
|
875
|
+
_add_check(checks, "tree_single_root_acyclic_reachable",
|
|
876
|
+
len(roots) == 1 and not cycle and seen == set(nodes),
|
|
877
|
+
{"nodes": len(nodes), "roots": roots, "cycle": cycle,
|
|
878
|
+
"unreachable": sorted(set(nodes) - seen)[:12]})
|
|
879
|
+
hollow = sorted(nid for nid, node in nodes.items() if not node.get("unit_ids") and not children[nid])
|
|
880
|
+
_add_check(checks, "no_empty_nodes", not hollow or not units, hollow[:12])
|
|
881
|
+
clashes = [f"{kid}: {nodes[kid].get('title')!r}" for kids in children.values()
|
|
882
|
+
for i, kid in enumerate(kids)
|
|
883
|
+
if normalize_title(str(nodes[kid].get("title"))) in
|
|
884
|
+
{normalize_title(str(nodes[other].get("title"))) for other in kids[:i]}]
|
|
885
|
+
_add_check(checks, "sibling_titles_distinct", not clashes, clashes[:12])
|
|
886
|
+
|
|
887
|
+
bad_ranges = []
|
|
888
|
+
for uid, unit in units.items():
|
|
889
|
+
ranges = unit_ranges(uid, unit, inventory)
|
|
890
|
+
if not ranges and not uid.startswith("grp:"):
|
|
891
|
+
bad_ranges.append(f"{uid}: no program-derived range")
|
|
892
|
+
for entry in ranges:
|
|
893
|
+
nlines = inventory.get(entry["path"], {}).get("nlines", 0)
|
|
894
|
+
for pair in entry["lines"]:
|
|
895
|
+
if not (1 <= pair[0] <= pair[1] <= nlines):
|
|
896
|
+
bad_ranges.append(f"{uid}:{entry['path']}:{pair}")
|
|
897
|
+
_add_check(checks, "unit_ranges_present_and_inbounds", not bad_ranges, bad_ranges[:12])
|
|
898
|
+
|
|
899
|
+
docs = run_dir / "docs"
|
|
900
|
+
links = sorted(docs.rglob("*.md"))
|
|
901
|
+
broken_links = []
|
|
902
|
+
page_texts = {}
|
|
903
|
+
for md in links:
|
|
904
|
+
text = md.read_text(encoding="utf-8")
|
|
905
|
+
page_texts[md.relative_to(docs).as_posix()] = text
|
|
906
|
+
# Undefined CommonMark references are literal text (e.g. Python a[b][c]).
|
|
907
|
+
# Only fN is our generated reference namespace; code examples are opaque.
|
|
908
|
+
link_text = prose_for_links(text)
|
|
909
|
+
references = dict(re.findall(r"^\[([^\]\n]+)\]:\s*(\S+)\s*$", link_text, re.MULTILINE))
|
|
910
|
+
for reference in re.findall(r"\[[^\]\n]+\]\[([^\]\n]+)\]", link_text):
|
|
911
|
+
if re.fullmatch(r"f\d+", reference) and reference not in references:
|
|
912
|
+
broken_links.append(f"{md.relative_to(docs)} -> [{reference}] (definition missing)")
|
|
913
|
+
targets = re.findall(r"\]\(([^)\s]+)\)", link_text) + list(references.values())
|
|
914
|
+
for target in targets:
|
|
915
|
+
if target.startswith(("http://", "https://", "mailto:")):
|
|
916
|
+
continue
|
|
917
|
+
file_part, _, fragment = target.partition("#")
|
|
918
|
+
resolved = (md.parent / file_part).resolve() if file_part else md
|
|
919
|
+
if not resolved.exists():
|
|
920
|
+
broken_links.append(f"{md.relative_to(docs)} -> {target}")
|
|
921
|
+
elif fragment and resolved.suffix == ".md" \
|
|
922
|
+
and f'id="{fragment}"' not in resolved.read_text(encoding="utf-8"):
|
|
923
|
+
broken_links.append(f"{md.relative_to(docs)} -> {target} (anchor missing)")
|
|
924
|
+
_add_check(checks, "relative_links_resolve", not broken_links,
|
|
925
|
+
{"checked": len(links), "broken": broken_links[:12]})
|
|
926
|
+
_verify_catalog(checks, run_dir, inventory, units, dispositions, owner, nodes,
|
|
927
|
+
inv_ids, page_texts)
|
|
928
|
+
_add_check(checks, "semantic_rendered", (docs / "INDEX.md").is_file() and (run_dir / "catalog.json").is_file())
|
|
929
|
+
return checks
|