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/prompts.py ADDED
@@ -0,0 +1,316 @@
1
+ """What each task's author is told: the rules and task bodies for every task kind."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from cbe import symbols
9
+ from cbe.checks import RELATION_KINDS
10
+ from cbe.projection import DEFAULT_GRAPH_MAX_TOKENS, graph_prompt_lines
11
+ from cbe.token_budget import count_text_tokens
12
+
13
+
14
+ TOOL_FREE_RULES = """You document source code in one response. Use only supplied source and signatures. Never use tools or ask to read files. Return exactly one JSON object, no Markdown fences. Separate observed syntax from inferred behavior; state uncertainty instead of inventing runtime facts. Be concise. Trivial accessors need no individual prose; tests that pin the same behavior share one explanation. Paths identify files; do not repeat large source passages."""
15
+
16
+
17
+ FILE_TASK_RULES = (
18
+ "Read every listed source file completely with your file-reading tool; page through long files. "
19
+ "Use only those source files, the supplied signatures, and the provided frozen graph evidence. "
20
+ "Do not open other source files, execute repository code, search the web, or modify the repository. "
21
+ "Use your file-reading tool for the listed files and your file-writing tool for the result file. "
22
+ "Do not run shell commands, scripts, or interpreters, not even to assemble or check the JSON: "
23
+ "in the user's harness each one asks for permission, and this task does not need them. "
24
+ "(If your harness has no file-reading or file-writing tool, use the plainest command that reads or writes that one file.) "
25
+ "You may Read a graph evidence file only when this task supplies its path. "
26
+ "Separate observed syntax from inferred behavior; state uncertainty instead "
27
+ "of inventing runtime facts. Be concise. Trivial accessors need no individual prose; tests that pin the same "
28
+ "behavior share one explanation. Paths identify files; do not repeat large source passages.")
29
+ NO_FILE_RULES = (
30
+ "Everything you need is in this file. Do not read source files, run code, search the web, or modify "
31
+ "the repository. Do not invent details that the supplied material does not show.")
32
+
33
+
34
+ def symbol_lines(paths: list[str], inventory: dict[str, dict], table: list[dict]) -> list[str]:
35
+ """The numbered symbol list: one line per symbol, grouped by file."""
36
+ rows_by_path: dict[str, list[dict]] = {}
37
+ for row in table:
38
+ rows_by_path.setdefault(row["path"], []).append(row)
39
+ lines = []
40
+ for path in paths:
41
+ entry = inventory[path]
42
+ lines.append(f"== {path} ({entry.get('tokens', 0)} tokens)"
43
+ + (" [empty file: no symbols]" if entry.get("known_empty") else ""))
44
+ for row in rows_by_path.get(path, []):
45
+ spans = ",".join(f"{a}-{b}" for a, b in row["lines"])
46
+ lines.append(f"{row['n']} {row['kind']} {row['name']} L{spans}")
47
+ return lines
48
+
49
+
50
+ def gap_note(gaps: list[dict], invalid: list[dict], problem: str | None = None) -> str:
51
+ """The text a detail re-ask appends to the unchanged first ask: only what is still missing."""
52
+ lines = ["", "", "FOLLOW-UP: the units you returned earlier for this batch were accepted and are kept. "
53
+ "Do not repeat them."]
54
+ if problem:
55
+ lines.append(f"Your last answer could not be used: {problem}")
56
+ if invalid:
57
+ lines.append("These units were rejected; return each again, corrected:")
58
+ lines += [f"- h={row['h']}: {row['why']}" for row in invalid]
59
+ if gaps:
60
+ lines.append("These functions, methods or classes with real logic still have no unit. Give each a "
61
+ "unit, alone or grouped with related symbols (tests that pin the same behavior go in one group):")
62
+ lines += [f"{row['n']} {row['kind']} {row['name']} ({row['path']})" for row in gaps]
63
+ lines.append("Return the same JSON shape with only the additional units, using the same symbol numbers.")
64
+ return "\n".join(lines)
65
+
66
+
67
+ def detail_prompt(paths: list[str], inventory: dict[str, dict], *,
68
+ chunks: list[dict] | None = None,
69
+ source_root: Path | None = None,
70
+ graph: dict | None = None,
71
+ graph_max_tokens: int = DEFAULT_GRAPH_MAX_TOKENS,
72
+ graph_file: str | None = None,
73
+ context_diagnostics: list[dict] | None = None,
74
+ issues: list[str] | None = None,
75
+ embed: bool = True) -> str:
76
+ """Build one detail task.
77
+
78
+ ``embed=True`` is the tool-free script driver: the source chunk text is
79
+ inlined once. ``embed=False`` is the host-agent driver: the prompt lists the
80
+ absolute file paths and chunk line ranges instead, so the text is not sent
81
+ twice. Graph context, when present, is attached once at chunk granularity;
82
+ a run without a graph says so instead of implying relations were analyzed.
83
+ """
84
+ table = symbols.batch_table(paths, inventory)
85
+ header = [
86
+ "You build compact structured detail units for a code-documentation pipeline. "
87
+ "Use ONLY the supplied source; never invent runtime behavior; record real "
88
+ "uncertainty in 'uncertainties' instead of guessing.",
89
+ "",
90
+ "OUTPUT: exactly one JSON object, no Markdown fences, no commentary:",
91
+ '{"units":[{"h":[12],"explain":"..."},{"h":[20,21,22],"title":"parse entrypoints","explain":"..."}],'
92
+ '"notes":"optional"}',
93
+ "",
94
+ "SYMBOLS below are numbered. Refer to a symbol only by its number; never write a name or "
95
+ "path where a number is asked for.",
96
+ "",
97
+ "units: objects with these fields",
98
+ " h (list of symbol numbers): the symbols this unit explains. One number: the unit is that "
99
+ "symbol. Several numbers: one grouped unit that explains them together; give it a short "
100
+ "'title'. A number may appear in one unit only.",
101
+ " title (string, grouped units only): a short name for the group.",
102
+ " explain (string): prose that lets a senior engineer understand this unit WITHOUT "
103
+ "reading the code and predict its boundary behavior. Cover responsibility and "
104
+ "boundary, key behavior, entry and input/output semantics, behavior-changing "
105
+ "conditions with their consequences, exception catch/propagate/fallback order, side "
106
+ "effects and state, and collaboration with unproven boundaries. Do not restate the "
107
+ "listing line by line; never paraphrase only the signature.",
108
+ " Do not emit a separate summary field: avoid repeating the same responsibility. "
109
+ "Start explain with a self-contained responsibility sentence of about 25 words or fewer; "
110
+ "the program reuses that first sentence under the signature and in the tree/INDEX. "
111
+ "Keep the remaining explanation for behavior the reader needs to predict.",
112
+ " extras (list, optional): {\"topic\": condition|failure|side_effect|field|contract, "
113
+ "\"note\": <one clear statement>} only for a necessity the explain does not cover.",
114
+ " relations (list, optional): {\"to\": <symbol number in this batch, or a name string "
115
+ "for a target outside it>, \"kind\": "
116
+ + str(sorted(RELATION_KINDS)) + ", \"basis\": <code evidence>}. kind=call only for an "
117
+ "invocation visible in the code; use 'use' when unsure.",
118
+ " uncertainties (list of strings, optional): real unproven boundaries of this reading.",
119
+ "Do NOT emit 'source', a kind, an id or any line-range field: the program derives them from "
120
+ "the numbered symbols.",
121
+ "",
122
+ "Symbols that no unit lists are indexed by position without prose. Leave out pure "
123
+ "constants, imports, re-exports and trivial accessors; give a unit to every other "
124
+ "function, method or class. Tests are no exception: group the tests that pin the same "
125
+ "behavior into one unit (h lists their numbers) and say in a sentence or two which "
126
+ "behavior they pin and any edge case worth knowing; do not write each test separately. "
127
+ "A file marked empty has no symbols and needs no unit; do not invent prose for it.",
128
+ ]
129
+ header.append(
130
+ "LENGTH GUIDANCE: Spend prose on behavior, not a fixed introduction for every unit. "
131
+ "Estimate each unit's source tokens from its own implementation (a class header excludes "
132
+ "separately documented members). For source under 50 tokens, one or two sentences usually "
133
+ "suffice; for 50-150 tokens, aim around 70 prose tokens or fewer. For 150-400 tokens, "
134
+ "around 110 prose tokens often suffice; for 400-1000, around 240; for larger units, "
135
+ "around 330, growing with actual complexity. These are references, not ceilings: exceed "
136
+ "them whenever needed to explain behavior-changing conditions, failure paths, or side "
137
+ "effects. Cover every behavior-changing branch and every failure path; let those branches "
138
+ "and paths determine the length. Information quality takes priority; remove repetition "
139
+ "before removing behavior.")
140
+ header.append(
141
+ "WRITE DIRECTLY, DO NOT COMPRESS: say the same facts in plainer words instead of dropping "
142
+ "them. When a unit has three or more conditions or cases, put each on its own line as "
143
+ "'- condition: outcome' after the responsibility sentence. Do not restate the signature, "
144
+ "parameter names or types; the page shows them above the prose. Put failure paths, "
145
+ "exceptions and side effects before routine detail.")
146
+ header.append(
147
+ "AVOID DUPLICATION: Explain a class's class-level responsibility and collaboration; "
148
+ "every class needs its own explanation, either as its own unit or, when it is grouped, "
149
+ "as a sentence in the group's explain that says what the class itself is; "
150
+ "do not repeat the behavior of members that have their own units in this batch. "
151
+ "For members folded into the class without their own units, explain their behavior, "
152
+ "conditions, and failure paths inside the class unit, not just class attributes. "
153
+ "Include those folded implementations when estimating the class unit's source size. "
154
+ "Keep the coordination needed to understand the class. Each unit must stand alone: "
155
+ "never replace behavior with 'see the class unit' or 'see above'. "
156
+ "A module region containing only imports or constants "
157
+ "gets no unit, because the symbol list already locates that non-behavioral material. "
158
+ "Write uncertainties only for a real boundary supported by specific source evidence; "
159
+ "omit boilerplate such as 'not visible in this file'. An empty uncertainties list is "
160
+ "appropriate when no such boundary affects understanding.")
161
+ header.append(
162
+ "BEHAVIOR AND EVIDENCE: State each if/elif branch or distinct case with its own condition "
163
+ "and outcome; do not compress different conditions into one ambiguous sentence. "
164
+ "Explain how a called function changes this unit's result, not merely that it is called, "
165
+ "when that effect is supported by the supplied source. Do not speculate about callers, "
166
+ "decorators, or safety guarantees absent from the source. If a relevant effect cannot "
167
+ "be established, put the specific boundary and its evidence in uncertainties. "
168
+ "Describe only code within this unit's source scope. When a docstring conflicts with "
169
+ "the implementation, follow the implementation and point out the discrepancy. "
170
+ "Keep generation metadata out of prose: no symbol numbers or positions, graph-candidate "
171
+ "labels, or references to the generation process.")
172
+ if issues:
173
+ header.append("Correct these review findings without broadening claims: "
174
+ + json.dumps(issues, ensure_ascii=False))
175
+ body: list[str] = ["SYMBOLS (this batch; number, kind, name, source lines):", *symbol_lines(paths, inventory, table), ""]
176
+ source_texts: dict[str, str] = {}
177
+ for path in paths:
178
+ text = ""
179
+ if source_root is not None:
180
+ try:
181
+ text = (source_root / path).read_bytes().decode("utf-8")
182
+ except (OSError, UnicodeDecodeError):
183
+ pass
184
+ if not text:
185
+ rows = sorted((c for c in (chunks or []) if c.get("path") == path),
186
+ key=lambda c: (c.get("start_byte") or 0, c.get("start_line") or 0))
187
+ text = "".join(c.get("text") or "" for c in rows)
188
+ source_texts[path] = text
189
+ source_tokens = sum(count_text_tokens(text) for text in source_texts.values())
190
+ graph_lines = graph_prompt_lines(graph, paths, embed=embed, inventory=inventory,
191
+ chunks=chunks, source_tokens=source_tokens,
192
+ max_tokens=graph_max_tokens, graph_file=graph_file)
193
+ body += graph_lines
194
+ if context_diagnostics:
195
+ body += ["SOURCE CONTEXT NOTES (program diagnostics, not model evidence):",
196
+ json.dumps(context_diagnostics[:10], ensure_ascii=False), ""]
197
+ if embed:
198
+ body.append("SOURCE (verbatim; the program derives all line ranges, so the model "
199
+ "never needs to emit one):")
200
+ for path in paths:
201
+ text = source_texts[path]
202
+ if text:
203
+ note = text
204
+ elif inventory.get(path, {}).get("known_empty"):
205
+ note = "(this file is empty: it has no source text, no inventory id and needs no unit)"
206
+ else:
207
+ note = "(source text was not available; describe only the listed symbols)"
208
+ body += [f"==== FILE {path} ====", note]
209
+ else:
210
+ body.append("SOURCE FILES (read each listed file completely; read no other file):")
211
+ for path in paths:
212
+ rows = [chunk for chunk in (chunks or []) if chunk.get("path") == path]
213
+ target = str((source_root or Path(".")) / path)
214
+ if rows:
215
+ spans = ", ".join(f"L{chunk['start_line']}-L{chunk['end_line']}" for chunk in rows)
216
+ body.append(f"- {target} ({spans})")
217
+ else:
218
+ body.append(f"- {target}")
219
+ return "\n".join(header + body)
220
+
221
+
222
+ TREE_NAME_INSTRUCTIONS = """You name the modules of a codebase. A program already grouped code units into modules, using call and import edges, shared files and similar wording. You do not regroup. You see no source code: each unit is one line of what it does. Units are numbered u1, u2, ...
223
+
224
+ For EVERY module below return:
225
+ - title: 2 to 6 words naming the module's responsibility. If the members come from one directory, still say what the module does. Titles must differ from each other.
226
+ - capability: ONE sentence of about 25 words or fewer: what the module is responsible for and why its members belong together.
227
+ - summary: 2 or 3 sentences: when a reader should come to this module and what they find there.
228
+ - evidence_units: 2 to 4 unit numbers of that module that show the capability best.
229
+
230
+ Answer with one JSON object and nothing else:
231
+ {"modules": [{"id": "M1", "title": "...", "capability": "...", "summary": "...", "evidence_units": ["u1", "u2"]}]}
232
+ """
233
+
234
+ TREE_TOP_INSTRUCTIONS = """You name the upper levels of a module tree of a codebase. The program fixed the structure: each GROUP below lists its children. A child is either a module that already has a title, or another group (named in this same answer: name the deepest groups first). You do not regroup or rename the children. You see no source code.
235
+
236
+ For EVERY group return:
237
+ - title: 2 to 6 words naming the shared responsibility of the group as a whole. Not a child's title repeated, not a directory name. Titles must differ from each other.
238
+ - capability: ONE sentence of about 25 words or fewer: what the group is responsible for and why these children belong together.
239
+ - summary: 2 or 3 sentences: when a reader should come here and how the children divide the work.
240
+ {root_note}
241
+ Answer with one JSON object and nothing else:
242
+ {"groups": [{"id": "G1", "title": "...", "capability": "...", "summary": "..."}]}
243
+ """
244
+
245
+
246
+ def tree_name_prompt(blocks: list[str]) -> str:
247
+ return TREE_NAME_INSTRUCTIONS + "\nMODULES:\n\n" + "\n\n".join(blocks)
248
+
249
+
250
+ def tree_top_prompt(repo: str, blocks: list[str]) -> str:
251
+ note = (f'G1 is the whole repository "{repo}": its title is the system\'s name or says what it is.\n')
252
+ return TREE_TOP_INSTRUCTIONS.replace("{root_note}", note) + "\nGROUPS:\n\n" + "\n\n".join(blocks)
253
+
254
+
255
+ def tree_retry_note(pending: list[str], problems: list[str], taken: list[str]) -> str:
256
+ """Appended to the first prompt when some nodes of a naming answer were rejected."""
257
+ return ("\n\nSome entries of your previous answer were accepted. Answer again ONLY for: "
258
+ + ", ".join(pending) + " (the same JSON shape, with only these entries). Problems: "
259
+ + "; ".join(problems[:20]) + ("\nTitles already taken, do not reuse: " + "; ".join(taken[:60]) if taken else "")
260
+ + "\n")
261
+
262
+
263
+ def review_prompt(units: dict | None, detail_task: str) -> str:
264
+ """An independent reader checks one batch's accepted units against the same source."""
265
+ return ("TASK: Check these structured detail units against the source. Return JSON "
266
+ "{\"decision\":\"accepted\" or \"needs_repair\",\"issues\":[...]} . issues is a list of "
267
+ "strings, not objects: one sentence per issue that names the unit id and the problem, for "
268
+ "example \"issues\": [\"flask/app.py::method::Flask.run::123: says X but the code does Y\"]. Only report "
269
+ "material unsupported or missing behavior, a wrong condition or failure surface, or a "
270
+ "unit attributed to the wrong symbol. Do not judge writing style.\n"
271
+ "DOCUMENTATION (structured units): " + json.dumps(units, ensure_ascii=False) + "\n\n" + detail_task)
272
+
273
+
274
+ def system_relationship_context(relationships: dict | None = None) -> dict:
275
+ """The exact bounded relationship table shown to the system model."""
276
+ from cbe.token_budget import count_text_tokens
277
+
278
+ relationships = relationships or {"dependencies": [], "candidate_flows": []}
279
+ context = {key: relationships.get(key) for key in
280
+ ("graph_state", "graph_reason", "graph_only_omitted")}
281
+ context.update(dependencies=list(relationships["dependencies"][:80]),
282
+ candidate_flows=list(relationships["candidate_flows"][:24]))
283
+ context["prompt_omitted"] = {key: len(relationships[key]) - len(context[key])
284
+ for key in ("dependencies", "candidate_flows")}
285
+ while count_text_tokens(json.dumps(context, ensure_ascii=False)) > 12000:
286
+ key = "candidate_flows" if context["candidate_flows"] else "dependencies"
287
+ if not context[key]:
288
+ break
289
+ context[key].pop()
290
+ context["prompt_omitted"][key] += 1
291
+ return context
292
+
293
+
294
+ def system_prompt(nodes: list[dict], limit: int, relationships: dict | None = None) -> str:
295
+ """Bound graph context to 12k tokens: remove last candidates, then dependencies."""
296
+ context = system_relationship_context(relationships)
297
+ summaries = [{"id": node.get("id"), "title": node.get("title"),
298
+ "capability": node.get("capability"), "summary": node.get("summary"),
299
+ "parent": node.get("parent"), "units": len(node.get("unit_ids") or [])}
300
+ for node in nodes]
301
+ return ("TASK: Synthesize these semantic node summaries and program-derived relationships into an overview "
302
+ f"of the repository. Return JSON with a nonempty string overview of 2 to 4 sentences, at most {limit} "
303
+ "characters: what the system is and how its top-level modules divide the work. Do not invent unseen "
304
+ "details. Also return dependency_descriptions: "
305
+ "[{\"from\":\"node id\",\"to\":\"node id\",\"description\":\"one sentence\"}], describing "
306
+ "at least one key dependency when the table is nonempty (otherwise []). Use existing pairs only; "
307
+ "describe only who uses whom for what. Do not write evidence/source words "
308
+ "(graph-confirmed, model-inferred, graph-only, resolved calls/edges/imports) or call/edge counts; "
309
+ "the program supplies those labels and counts. Return key_flows: [{\"name\":\"short name\","
310
+ "\"description\":\"one sentence\",\"steps\":[\"unit id\",\"unit id\"]}]. Select 3-7 key flows "
311
+ "from candidates and keep their unit ids. If fewer than three candidates exist, you may describe "
312
+ "distinct useful subpaths using those same ids. When there are no candidates, return []. "
313
+ "Use 2-8 steps per flow, at most 300 characters per name or description. Describe how the parts "
314
+ "cooperate; link to details rather than repeating unit explanations. Unsupported transitions "
315
+ "will be labeled model inferred by the program.\nRELATIONSHIPS: "
316
+ + json.dumps(context, ensure_ascii=False) + "\nNODE SUMMARIES: " + json.dumps(summaries, ensure_ascii=False))
cbe/records.py ADDED
@@ -0,0 +1,200 @@
1
+ """Published records: one authority for prose, source identity and graph evidence."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import re
6
+ from collections import defaultdict
7
+
8
+ from cbe.symbols import SemanticError
9
+
10
+
11
+ def summary_sentence(unit: dict) -> str:
12
+ """Use the author's optional sentence, or the first explanation sentence."""
13
+ summary = unit.get("summary")
14
+ if isinstance(summary, str) and summary.strip():
15
+ return summary.strip()
16
+ text = (unit.get("explain") or "").strip()
17
+ for match in re.finditer(r"[.!?](?=\s|$)|[。!?]", text):
18
+ if re.search(r"\b(?:i\.e|e\.g)\.$", text[:match.end()], re.IGNORECASE):
19
+ continue
20
+ return text[:match.end()]
21
+ return text.split("\n", 1)[0]
22
+
23
+
24
+ def summary_and_remainder(unit: dict) -> tuple[str, str]:
25
+ """Keep the explanation once when its prefix already serves as summary."""
26
+ summary = unit.get("summary") or ""
27
+ explain = (unit.get("explain") or "").strip()
28
+ if summary and explain.startswith(summary):
29
+ explain = explain[len(summary):].lstrip()
30
+ return summary, explain
31
+
32
+
33
+ def frozen_sources(context: dict, paths: list[str] | None = None) -> dict[str, str]:
34
+ """Reassemble and SHA-256-check every selected frozen file before returning.
35
+
36
+ Chunks are exclusive UTF-8 byte slices, not whole declarations. Concatenating
37
+ them by byte offset recovers original bytes (including CRLF). The whole-file
38
+ digest also rejects missing, duplicated, overlapping or tampered chunks.
39
+ """
40
+ files = {row["path"]: row for row in context.get("files") or []}
41
+ selected = sorted(files if paths is None else set(paths))
42
+ sources = {}
43
+ for path in selected:
44
+ if path not in files:
45
+ raise SemanticError(f"no frozen source file: {path}")
46
+ parts = sorted((c for c in context.get("chunks") or [] if c["path"] == path),
47
+ key=lambda c: c.get("start_byte", 0))
48
+ raw = (files[path]["source"].encode("utf-8") if "source" in files[path] else
49
+ b"".join(c.get("text", "").encode("utf-8") for c in parts))
50
+ if hashlib.sha256(raw).hexdigest() != files[path].get("sha256"):
51
+ raise SemanticError(f"frozen source digest mismatch: {path}; no code printed")
52
+ sources[path] = raw.decode("utf-8")
53
+ return sources
54
+
55
+
56
+ class SourceOwnership:
57
+ """Resolve coordinates to exclusive frozen chunks, including graph callers."""
58
+
59
+ def __init__(self, sources: dict[str, str], chunks: list[dict], symbol_ids):
60
+ self.starts = {}
61
+ self.blocks = defaultdict(list)
62
+ for path, text in sources.items():
63
+ raw = text.encode("utf-8")
64
+ self.starts[path] = (raw, [0] + [i + 1 for i, b in enumerate(raw) if b == 10])
65
+ for chunk in chunks:
66
+ if chunk["id"] in symbol_ids:
67
+ self.blocks[chunk["path"]].append(chunk)
68
+
69
+ def at(self, path, line, column=None):
70
+ """Return the symbol owning a one-based source coordinate, or None."""
71
+ if path not in self.starts or not isinstance(line, int) or line < 1:
72
+ return None
73
+ raw, offsets = self.starts[path]
74
+ if line > len(offsets):
75
+ return None
76
+ point = offsets[line - 1]
77
+ if column is None:
78
+ while point < len(raw) and raw[point] in b" \t":
79
+ point += 1
80
+ elif isinstance(column, int) and column >= 1:
81
+ end = offsets[line] if line < len(offsets) else len(raw)
82
+ text = raw[point:end].decode("utf-8")
83
+ if column > len(text) + 1:
84
+ return None
85
+ point += len(text[:column - 1].encode("utf-8"))
86
+ else:
87
+ return None
88
+ for chunk in self.blocks[path]:
89
+ if chunk.get("start_byte", -1) <= point < chunk.get("end_byte", -1):
90
+ return chunk["id"]
91
+ return None
92
+
93
+ def edge_source(self, edge: dict, nodes: dict):
94
+ source = nodes.get(edge.get("source"), {})
95
+ if edge.get("kind") == "call":
96
+ return self.at(edge.get("path") or source.get("path"), edge.get("line"), edge.get("column"))
97
+ owner = nodes.get(source.get("owner"), source)
98
+ return self.at(owner.get("path"), owner.get("start_line"))
99
+
100
+
101
+ def build_records(units: dict, dispositions: dict, inventory: dict, ranges: dict,
102
+ sources: dict[str, str], languages: dict[str, str],
103
+ context: dict | None, graph: dict | None,
104
+ edge_table: dict | None = None) -> tuple[dict, dict]:
105
+ """Return unit and inventory-symbol records, keeping original evidence verbatim."""
106
+ from cbe.source_context import source_signatures
107
+ from cbe.graph import built_without_dataflow, graph_state
108
+
109
+ chunks = {c["id"]: c for c in (context or {}).get("chunks") or []}
110
+ signatures = {path: source_signatures(path, text, languages.get(path, "unknown"))
111
+ for path, text in sources.items()}
112
+ symbols = {}
113
+ for path, entry in inventory.items():
114
+ for symbol in entry["symbols"]:
115
+ sid = symbol["id"]
116
+ chunk = chunks.get(sid, {})
117
+ declaration = chunk.get("symbol_id") or re.sub(r"::s\d+$", "", sid)
118
+ by_id, by_location = signatures.get(path, ({}, {}))
119
+ location = (symbol["name"], symbol["lines"][0][0])
120
+ signature = by_id.get(declaration, by_location.get(location,
121
+ chunk.get("signature") or ""))
122
+ symbols[sid] = {"id": sid, "kind": symbol["kind"], "name": symbol["name"],
123
+ "file": path, "signature": signature,
124
+ "ranges": [{"path": path, "lines": symbol["lines"]}]}
125
+
126
+ def resolve(sid):
127
+ return sid if sid in units else (dispositions.get(sid) or {}).get("unit")
128
+
129
+ reverse = defaultdict(list)
130
+ for uid, unit in units.items():
131
+ for relation in unit.get("relations") or []:
132
+ target = resolve(relation.get("target"))
133
+ if target in units:
134
+ reverse[target].append({"source": uid, **relation})
135
+
136
+ ownership = SourceOwnership(sources, list(chunks.values()), symbols)
137
+
138
+ graph_nodes = {n["id"]: n for n in (graph or {}).get("nodes") or []}
139
+ node_symbols = {}
140
+ for nid, node in graph_nodes.items():
141
+ owner = graph_nodes.get(node.get("owner"), node)
142
+ node_symbols[nid] = ownership.at(owner.get("path"), owner.get("start_line"))
143
+ outgoing, incoming = defaultdict(list), defaultdict(list)
144
+ internal = defaultdict(lambda: defaultdict(int))
145
+ edge_table = {} if edge_table is None else edge_table
146
+ for edge in (graph or {}).get("edges") or []:
147
+ source = graph_nodes.get(edge.get("source"), {})
148
+ caller = ownership.edge_source(edge, graph_nodes)
149
+ callee = node_symbols.get(edge.get("target"))
150
+ if edge.get("kind") in {"cfg", "dataflow"}:
151
+ if caller:
152
+ internal[caller][edge["kind"]] += 1
153
+ continue
154
+ evidence = {key: edge[key] for key in ("id", "kind", "status", "source", "target", "path", "line", "column")
155
+ if key in edge}
156
+ evidence.update(source_symbol=caller, target_symbol=callee,
157
+ source_name=symbols.get(caller, {}).get("name") or source.get("name") or edge.get("source"),
158
+ target_name=symbols.get(callee, {}).get("name") or graph_nodes.get(edge.get("target"), {}).get("name") or edge.get("expression") or edge.get("method_full_name") or edge.get("target") or "unknown target")
159
+ edge_table[edge["id"]] = evidence
160
+ if caller:
161
+ outgoing[caller].append(edge["id"])
162
+ if callee:
163
+ incoming[callee].append(edge["id"])
164
+ availability = graph_state(graph)[0]
165
+ no_dataflow = built_without_dataflow(graph) # then a count of 0 would be a false statement
166
+ for sid, symbol in symbols.items():
167
+ symbol.update(graph_state=availability, outgoing=outgoing[sid], incoming=incoming[sid],
168
+ internal_edge_counts=dict(internal[sid]))
169
+
170
+ records = {}
171
+ for uid, unit in units.items():
172
+ declared_members = [member for member in unit.get("members") or [] if isinstance(member, str)]
173
+ members = list(dict.fromkeys(([uid] if uid in symbols else []) + declared_members +
174
+ [sid for sid, d in dispositions.items() if d.get("unit") == uid]))
175
+ signature_rows = [{"id": sid, "signature": symbols[sid]["signature"]}
176
+ for sid in members if sid in symbols]
177
+ primary_id = next((row["id"] for row in signature_rows if row["signature"]),
178
+ next((sid for sid in members if sid in symbols), None))
179
+ complete_ranges = {row["path"]: [pair[:] for pair in row["lines"]] for row in ranges[uid]}
180
+ for sid in members:
181
+ for row in symbols.get(sid, {}).get("ranges", []):
182
+ pairs = complete_ranges.setdefault(row["path"], [])
183
+ pairs.extend(pair[:] for pair in row["lines"] if pair not in pairs)
184
+ effective_ranges = [{"path": path, "lines": pairs} for path, pairs in sorted(complete_ranges.items())]
185
+ primary_range = (symbols[primary_id]["ranges"][0] if primary_id else
186
+ next(iter(effective_ranges), None))
187
+ record = {**unit, "id": uid, "summary": summary_sentence(unit),
188
+ "ranges": effective_ranges, "primary_range": primary_range, "signatures": signature_rows,
189
+ "signature": next((r["signature"] for r in signature_rows if r["signature"]), ""),
190
+ "members": unit.get("members") or [], "folded_members": [m for m in members if m != uid],
191
+ "reverse_relations": reverse[uid], "graph_state": availability}
192
+ for direction, edges in (("outgoing", outgoing), ("incoming", incoming)):
193
+ record[direction] = list(dict.fromkeys(eid for sid in members for eid in edges[sid]))
194
+ record["internal_edge_counts"] = {
195
+ kind: sum(internal[sid].get(kind, 0) for sid in members)
196
+ for kind in (("cfg",) if no_dataflow else ("cfg", "dataflow"))}
197
+ for field in ("extras", "relations", "uncertainties"):
198
+ record.setdefault(field, [])
199
+ records[uid] = record
200
+ return records, symbols