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
@@ -0,0 +1,43 @@
1
+ """Backend registry for the public ``cbe-graph/1`` graph analyzer.
2
+
3
+ Each backend wraps one upstream open-source analyzer and returns a fragment in
4
+ the standard node/edge shape. ``cbe.graph`` merges fragments, owns the schema
5
+ and never re-implements language analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._paths import canonical_repo_path
11
+ from .imports import (
12
+ DEPCRUISE_LANGUAGES,
13
+ build_with_depcruise,
14
+ build_with_grimp,
15
+ find_depcruise,
16
+ )
17
+ from .joern import (
18
+ FRONTEND_NAME,
19
+ JOERN_BYTECODE_FRONTENDS,
20
+ JOERN_LANGUAGE_FLAGS,
21
+ JoernError,
22
+ build_with_joern,
23
+ joern_version,
24
+ )
25
+ from .toolchain import ToolchainError, find_java, find_joern, setup_hint
26
+
27
+ __all__ = [
28
+ "DEPCRUISE_LANGUAGES",
29
+ "FRONTEND_NAME",
30
+ "JOERN_BYTECODE_FRONTENDS",
31
+ "JOERN_LANGUAGE_FLAGS",
32
+ "JoernError",
33
+ "ToolchainError",
34
+ "build_with_depcruise",
35
+ "build_with_grimp",
36
+ "build_with_joern",
37
+ "canonical_repo_path",
38
+ "find_depcruise",
39
+ "find_java",
40
+ "find_joern",
41
+ "joern_version",
42
+ "setup_hint",
43
+ ]
@@ -0,0 +1,262 @@
1
+ """One call-resolution policy for normalized edges and capability counts."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import builtins
6
+ from pathlib import PurePosixPath
7
+ import posixpath
8
+ import re
9
+
10
+
11
+ _DOTTED_TYPE = re.compile(r"(?:[A-Za-z_]\w*\.)+([A-Za-z_]\w*)")
12
+
13
+
14
+ def csharp_signature_key(full: str) -> str:
15
+ """One key for both spellings of a C# method's signature.
16
+
17
+ Joern's C# frontend writes a repository type with its namespace in the method's own full
18
+ name (``N.Guard.NotNull:T(N.IGuardClause,T)``) but without it in the full name of a call
19
+ to that method (``N.Guard.NotNull:T(IGuardClause,T)``). The method's qualified name before
20
+ the colon is kept as it is; inside the signature every dotted type name, generic
21
+ arguments included, is cut to its last component.
22
+ """
23
+ head, colon, signature = full.partition(":")
24
+ return head + colon + _DOTTED_TYPE.sub(r"\1", signature) if colon else full
25
+
26
+
27
+ _UNKNOWN_NAMES = ("<unresolvedNamespace>", "<unresolvedName>", "<unresolvedSignature>",
28
+ "<unknownFullName>", "<empty>")
29
+
30
+
31
+ class CallResolver:
32
+ def __init__(self, methods: list[dict], files=None, types=None, go_modules=None,
33
+ javascript_packages=None):
34
+ self.methods = {int(m["nodeId"]): m for m in methods if m.get("nodeId") is not None}
35
+ self.method_names = {m.get("name") for m in methods}
36
+ self.files = set(files) if files is not None else {m.get("file", "") for m in methods if m.get("file")}
37
+ self.diagnostics: list[dict] = []
38
+ self.go_modules = go_modules or {}
39
+ self.javascript_packages = set(javascript_packages or ())
40
+ self.python_modules = set()
41
+ self.python_top_modules = set()
42
+ for path in self.files:
43
+ if path.endswith(".py"):
44
+ module = path[:-3].removesuffix("/__init__").removeprefix("src/")
45
+ self.python_modules.add(PurePosixPath(path).stem)
46
+ top = module.split("/", 1)[0]
47
+ if top:
48
+ self.python_top_modules.add(top)
49
+ self.python_modules.add(top)
50
+ self.by_file: dict[str, dict[str, list[int]]] = {}
51
+ self.by_pair: dict[str, list[int]] = {}
52
+ self.by_full: dict[str, list[int]] = {}
53
+ self.by_csharp_key: dict[str, list[int]] = {}
54
+ self.by_csharp_name: dict[str, list[int]] = {}
55
+ self.internal_prefixes = set()
56
+ self.classes = {"Self"}
57
+ method_names = {m.get("fullName") for m in methods}
58
+ # pysrc2cpg also emits TYPE_DECL for every function. Those entries
59
+ # have the exact identity of a method, unlike their owning class.
60
+ self.types = {t["fullName"]: t for t in (types or []) if t.get("file") in self.files
61
+ and not (str(t.get("file", "")).endswith(".py") and t["fullName"] in method_names)}
62
+ self.types_by_name = {}
63
+ for full, entry in self.types.items():
64
+ name = entry.get("name") or full.split(".")[-1]
65
+ self.internal_prefixes.add(full)
66
+ if "." in full and not str(entry.get("file", "")).endswith(".py"):
67
+ self.internal_prefixes.add(full.rsplit(".", 1)[0])
68
+ self.classes.add(name)
69
+ self.types_by_name.setdefault(name, []).append(full)
70
+ for native, method in self.methods.items():
71
+ full = method.get("fullName", "")
72
+ owner = full.rsplit(".", 1)[0]
73
+ if (owner and owner != full and "<clinit>" not in full
74
+ and not str(method.get("file", "")).endswith(".py")):
75
+ self.internal_prefixes.add(owner)
76
+ self.by_full.setdefault(full, []).append(native)
77
+ if str(method.get("file", "")).endswith(".cs") and ":" in full:
78
+ self.by_csharp_key.setdefault(csharp_signature_key(full), []).append(native)
79
+ self.by_csharp_name.setdefault(full.partition(":")[0], []).append(native)
80
+ by_file = self.by_file.setdefault(method.get("file", ""), {})
81
+ by_file.setdefault(full.partition(":")[2], []).append(native)
82
+ member = full.rsplit(":", 1)[-1]
83
+ if member != full.partition(":")[2]:
84
+ by_file.setdefault(member, []).append(native)
85
+ # Python frontend: path.py:<module>.Class.method. Do not match
86
+ # a bare method name, which confuses unrelated receiver types.
87
+ name = full.split(":")[-1]
88
+ parts = name.split(".")
89
+ if len(parts) >= 2 and not any(p.startswith("<") for p in parts[-2:]):
90
+ self.by_pair.setdefault(".".join(parts[-2:]), []).append(native)
91
+ if (str(method.get("file", "")).endswith(".py") and ":<module>." in full
92
+ and full.rsplit(".", 1)[0] not in method_names):
93
+ self.classes.add(parts[-2])
94
+
95
+ def inherited_targets(self, receiver, name, call):
96
+ if receiver in {"Self", "te.Self"}:
97
+ native_caller = call.get("callerId")
98
+ caller = self.methods.get(int(native_caller), {}) if native_caller is not None else {}
99
+ owners = [caller.get("fullName", "").rsplit(".", 1)[0]]
100
+ else:
101
+ owners = self.types_by_name.get(receiver, [])
102
+ if len(owners) != 1:
103
+ return []
104
+ visited = set()
105
+ def search(owner):
106
+ if owner in visited:
107
+ return []
108
+ visited.add(owner)
109
+ direct = self.by_full.get(owner + "." + name, [])
110
+ if direct:
111
+ return direct
112
+ targets = []
113
+ for base in self.types.get(owner, {}).get("inherits", []):
114
+ # Only traverse exact analyzer type identities. A short-name
115
+ # collision is insufficient evidence of an inheritance edge.
116
+ targets.extend(search(base))
117
+ return targets
118
+ return list(dict.fromkeys(search(owners[0])))
119
+
120
+ def resolve(self, call: dict) -> tuple[str, list[int], list[str]]:
121
+ result = self._resolve(call)
122
+ status, _, external_names = result
123
+ # Enforce Python's external identity contract at the single exit,
124
+ # including future policy branches that return an external target.
125
+ if (status == "external" and str(call.get("callerFile", "")).endswith(".py")
126
+ and (not external_names or any(".py:" not in name and not name.startswith("__builtin")
127
+ for name in external_names))):
128
+ self.diagnostics.append({
129
+ "path": call.get("callerFile"), "line": call.get("line"),
130
+ "code": "python_external_name_unverified", "analyzer_id": call.get("nodeId"),
131
+ "message": f"Python external target lacks a library identity: {external_names!r}",
132
+ })
133
+ return "unresolved", [], []
134
+ return result
135
+
136
+ def _resolve(self, call: dict) -> tuple[str, list[int], list[str]]:
137
+ callees = call.get("callees") or []
138
+ internal = list(dict.fromkeys(int(c["nodeId"]) for c in callees
139
+ if not c.get("external") and c.get("nodeId") is not None
140
+ and int(c["nodeId"]) in self.methods))
141
+ full = call.get("methodFullName") or ""
142
+ if internal:
143
+ # Name-only binding with an unknown receiver is not a resolution.
144
+ if full == "<unknownFullName>":
145
+ return "ambiguous", internal, []
146
+ return ("resolved" if len(internal) == 1 else "ambiguous", internal, [])
147
+ # Python's frontend leaves builtin exception constructors without any
148
+ # callee and with an unknown full name. A known builtin expression is
149
+ # stronger evidence than that missing analyzer name.
150
+ if not callees and str(call.get("callerFile", "")).endswith(".py"):
151
+ try:
152
+ expression = ast.parse(call.get("code", ""), mode="eval").body
153
+ except SyntaxError:
154
+ expression = None
155
+ if isinstance(expression, ast.Call) and isinstance(expression.func, ast.Name):
156
+ name = expression.func.id
157
+ if callable(getattr(builtins, name, None)):
158
+ return "external", [], ["__builtin." + name]
159
+ if any(marker in full for marker in _UNKNOWN_NAMES) or any(
160
+ any(marker in str(c.get("fullName") or "") for marker in _UNKNOWN_NAMES) for c in callees):
161
+ return "unresolved", [], []
162
+ relative = self._relative_typescript_target(call, full)
163
+ if relative is not None:
164
+ return relative
165
+ # A leading // is a relative import whose package prefix was lost.
166
+ # Search only package ancestors; an unrelated basename is not evidence.
167
+ if full.startswith("//") and ".py:<module>." in full and str(call.get("callerFile", "")).endswith(".py"):
168
+ relative, _, member = full[2:].partition(":")
169
+ caller = PurePosixPath(call["callerFile"])
170
+ candidates = {str(parent / relative) for parent in caller.parents} & self.files
171
+ if len(candidates) == 1:
172
+ matches = self.by_file.get(next(iter(candidates)), {}).get(member, [])
173
+ if len(matches) == 1:
174
+ return "resolved", matches, []
175
+ return "unresolved", [], []
176
+ exact = self.by_full.get(full, [])
177
+ if exact:
178
+ return ("resolved" if len(exact) == 1 else "ambiguous", exact, [])
179
+ if str(call.get("callerFile", "")).endswith(".cs") and ":" in full:
180
+ matches = self.by_csharp_key.get(csharp_signature_key(full), [])
181
+ if matches:
182
+ # The frontend's signature for the call is not reliable enough to pick an
183
+ # overload (it types -1L and 0.0 as Int32), so an overloaded method is a
184
+ # candidate set, not a resolution.
185
+ overloads = self.by_csharp_name.get(full.partition(":")[0], [])
186
+ if len(matches) == 1 and len(overloads) == 1:
187
+ return "resolved", matches, []
188
+ return "ambiguous", sorted(set(matches) | set(overloads)), []
189
+ # A repository-qualified miss is analyzer guesswork, not a known
190
+ # external target. Never salvage its last two components.
191
+ if self._repository_qualified(full):
192
+ return "unresolved", [], []
193
+ if re.fullmatch(r"[A-Za-z_]\w*\.[A-Za-z_]\w*", full):
194
+ matches = self.by_pair.get(full, [])
195
+ if matches:
196
+ return ("resolved" if len(matches) == 1 else "ambiguous", matches, [])
197
+ receiver, _, name = full.rpartition(".")
198
+ if ((".py:" not in full and full.split(".", 1)[0] in self.classes)
199
+ or full.startswith("te.Self.")):
200
+ matches = self.inherited_targets(receiver, name, call)
201
+ if matches:
202
+ return ("resolved" if len(matches) == 1 else "ambiguous", matches, [])
203
+ return "unresolved", [], []
204
+ if (".py:" not in full and not full.startswith("__builtin")
205
+ and ("<returnValue>" in full or "<member>(" in full
206
+ or (str(call.get("callerFile", "")).endswith(".py")
207
+ and full.split(".", 1)[0] in self.python_modules))):
208
+ return "unresolved", [], []
209
+ external = list(dict.fromkeys(c.get("fullName") or call.get("name", "") for c in callees if c.get("external")))
210
+ if (full.startswith("ANY.") and str(call.get("callerFile", "")).endswith(
211
+ (".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh", ".go"))):
212
+ return "unresolved", [], []
213
+ if str(call.get("callerFile", "")).endswith((".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh")):
214
+ if (not full.startswith(("std.", "__gnu"))
215
+ and re.fullmatch(r"(?:[A-Za-z_]\w*\.)*([A-Za-z_]\w*)\.\1(?::.*)?", full)):
216
+ return "unresolved", [], []
217
+ if full.split(":", 1)[0] in self.method_names:
218
+ return "unresolved", [], []
219
+ if any(self._repository_qualified(name) for name in external):
220
+ return "unresolved", [], []
221
+ # An internal callee missing from the exported method set is unknown.
222
+ return ("external", [], external) if external else ("unresolved", [], [])
223
+
224
+ def _repository_qualified(self, full: str) -> bool:
225
+ if any(full.startswith(path + ":") for path in self.files):
226
+ return True
227
+ if ".py:" in full:
228
+ module = full.split(".py:", 1)[0]
229
+ if any(module == top or module.startswith(top + "/") for top in self.python_top_modules):
230
+ return True
231
+ if any(full == module or full.startswith(module + ".") or full.startswith(module + "/")
232
+ for module in self.go_modules):
233
+ return True
234
+ if any(full == package or full.startswith(package + ":") for package in self.javascript_packages):
235
+ return True
236
+ return any(prefix and full.startswith(prefix + ".") for prefix in self.internal_prefixes)
237
+
238
+ def _relative_typescript_target(self, call: dict, full: str):
239
+ path = str(call.get("callerFile", ""))
240
+ if not path.endswith((".ts", ".tsx", ".mts", ".cts")) or ":" not in full:
241
+ return None
242
+ relative, _, member = full.partition(":")
243
+ if not relative.endswith((".js", ".jsx", ".mjs", ".cjs")):
244
+ return None
245
+ stem = posixpath.normpath(posixpath.join(posixpath.dirname(path), relative))
246
+ if stem == ".." or stem.startswith("../"):
247
+ return "unresolved", [], []
248
+ replacements = {".js": (".ts", ".tsx"), ".jsx": (".tsx",),
249
+ ".mjs": (".mts",), ".cjs": (".cts",)}
250
+ suffix = next(suffix for suffix in replacements if stem.endswith(suffix))
251
+ base = stem[:-len(suffix)]
252
+ candidates = [base + ext for ext in replacements[suffix]]
253
+ candidates += [base + "/index" + ext for ext in (".ts", ".tsx")]
254
+ candidates = [candidate for candidate in candidates if candidate in self.files]
255
+ matches = list(dict.fromkeys(
256
+ native for candidate in candidates
257
+ for native in self.by_file.get(candidate, {}).get(member, [])))
258
+ if len(matches) == 1:
259
+ return "resolved", matches, []
260
+ if len(matches) > 1:
261
+ return "ambiguous", matches, []
262
+ return ("unresolved", [], []) if candidates else None
@@ -0,0 +1,42 @@
1
+ """Canonical repo-relative path handling shared by the graph layer.
2
+
3
+ ``lstrip("./")`` is wrong: it turns ``../foo`` into ``foo`` and ``.hidden``
4
+ into ``hidden``. A query or manifest path must be a normalized POSIX path
5
+ inside the repository, or be rejected.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _WINDOWS_DRIVE = re.compile(r"^[A-Za-z]:")
13
+
14
+
15
+ def canonical_repo_path(raw: object) -> str | None:
16
+ """Return a canonical repo-relative POSIX path, or ``None`` if unsafe.
17
+
18
+ ``./a/b`` -> ``a/b``; ``a/../b`` -> ``b``; ``../a`` -> ``None`` (escapes
19
+ the root); absolute paths and Windows drive paths -> ``None``. Dot-files
20
+ such as ``.gitignore`` are preserved.
21
+ """
22
+
23
+ if raw is None:
24
+ return None
25
+ text = str(raw).replace("\\", "/").strip()
26
+ if not text:
27
+ return None
28
+ if text.startswith("/") or _WINDOWS_DRIVE.match(text):
29
+ return None
30
+ parts: list[str] = []
31
+ for part in text.split("/"):
32
+ if part in ("", "."):
33
+ continue
34
+ if part == "..":
35
+ if not parts:
36
+ return None
37
+ parts.pop()
38
+ continue
39
+ parts.append(part)
40
+ if not parts:
41
+ return None
42
+ return "/".join(parts)
@@ -0,0 +1,93 @@
1
+ """Repository file membership and canonical graph identities.
2
+
3
+ All backends and the merge boundary use the same excluded-directory policy.
4
+ A file identity can only be constructed for an inventoried repository file.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ from pathlib import Path
11
+
12
+ from cbe.grammars import DEFAULT_EXCLUDE_DIR_NAMES
13
+ from ._paths import canonical_repo_path
14
+
15
+
16
+ def repository_files(repo: Path) -> frozenset[str]:
17
+ repo = repo.resolve()
18
+ files = set()
19
+ for directory, dirs, names in os.walk(repo, followlinks=False):
20
+ dirs[:] = sorted(d for d in dirs if d not in DEFAULT_EXCLUDE_DIR_NAMES
21
+ and not (Path(directory) / d).is_symlink())
22
+ for name in names:
23
+ full = Path(directory) / name
24
+ if full.is_file() and not full.is_symlink():
25
+ files.add(full.relative_to(repo).as_posix())
26
+ return frozenset(files)
27
+
28
+
29
+ def file_id(path: str | None, files) -> str | None:
30
+ return "file:" + path if path and path in files and canonical_repo_path(path) == path else None
31
+
32
+
33
+ def external_id(name: str) -> str:
34
+ return "ext:" + name
35
+
36
+
37
+ def validate_identities(nodes, edges, files):
38
+ """Validate backend identities; import resolution belongs to the resolver."""
39
+ aliases, invalid, diagnostics, result = {}, set(), [], []
40
+ for node in nodes:
41
+ native = node["id"]
42
+ if native.startswith("file:"):
43
+ canonical = file_id(node.get("path"), files)
44
+ if canonical is None:
45
+ invalid.add(native)
46
+ diagnostics.append(dict(path=node.get("path"), code="invalid_file_identity",
47
+ message=f"discarded non-repository file identity {native}"))
48
+ continue
49
+ aliases[native] = canonical
50
+ node = {**node, "id": canonical}
51
+ result.append(node)
52
+ unique = {n["id"]: n for n in result}
53
+ repaired = []
54
+ include_evidence = {}
55
+ for edge in edges:
56
+ if edge.get("kind") != "import" or edge.get("status") != "resolved":
57
+ continue
58
+ key = (edge.get("source"), edge.get("target"))
59
+ evidence = include_evidence.setdefault(key, {"angle": set(), "quoted": set()})
60
+ for expression in edge.get("expressions") or [edge.get("expression") or ""]:
61
+ match = re.fullmatch(r'#\s*include\s*([<"])([^>"]+)[>"]\s*', expression)
62
+ if match:
63
+ delimiter, token = match.groups()
64
+ if delimiter == '<' and '/' not in token and not edge.get("resolution_basis"):
65
+ evidence["angle"].add(token)
66
+ elif delimiter == '"':
67
+ evidence["quoted"].add(token)
68
+ for (source, target), evidence in include_evidence.items():
69
+ if evidence["angle"] and evidence["quoted"]:
70
+ diagnostics.append(dict(path=source, code="external_header_collision",
71
+ message=f"external header and repository header share target {target}",
72
+ angle_headers=sorted(evidence["angle"]), quoted_headers=sorted(evidence["quoted"])))
73
+ for edge in edges:
74
+ edge = dict(edge)
75
+ degraded = False
76
+ for endpoint in ("source", "target"):
77
+ value = edge.get(endpoint)
78
+ if value in invalid:
79
+ edge[endpoint] = None
80
+ degraded = True
81
+ elif value in aliases:
82
+ edge[endpoint] = aliases[value]
83
+ if endpoint == "target" and edge.get("kind") == "import":
84
+ edge["status"] = "resolved"
85
+ if degraded:
86
+ edge["status"] = "unresolved"
87
+ if (edge.get("kind") == "import" and edge.get("source") is not None
88
+ and edge.get("source") == edge.get("target")):
89
+ diagnostics.append(dict(path=edge.get("path"), code="self_import_suppressed",
90
+ message=f"discarded self import {edge['source']}", expression=edge.get("expression")))
91
+ continue
92
+ repaired.append(edge)
93
+ return list(unique.values()), repaired, diagnostics