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
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Import declaration resolution and file identity, shared by graph backends.
|
|
2
|
+
|
|
3
|
+
Resolution is static and repository bounded: it never imports project code or
|
|
4
|
+
searches the host environment. Absolute Python dependencies outside the source
|
|
5
|
+
roots are external, whether installed on this machine or not.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path, PurePosixPath
|
|
12
|
+
|
|
13
|
+
from ._paths import canonical_repo_path
|
|
14
|
+
from .identity import repository_files, file_id, external_id
|
|
15
|
+
from cbe.grammars import suffix_languages
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# System header namespaces documented by POSIX Issue 8, <sys/...>,
|
|
19
|
+
# <netinet/in.h>, <arpa/inet.h>, and Linux man-pages, headers(7).
|
|
20
|
+
# These namespaces never establish a repository include search root.
|
|
21
|
+
SYSTEM_INCLUDE_PREFIXES = frozenset({"sys", "netinet", "arpa", "net", "linux", "asm", "asm-generic"})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def go_modules(repo: Path, files) -> dict[str, str]:
|
|
25
|
+
"""Read root and nested Go module identities without searching the host."""
|
|
26
|
+
modules = {}
|
|
27
|
+
for path in sorted(files):
|
|
28
|
+
if PurePosixPath(path).name != "go.mod":
|
|
29
|
+
continue
|
|
30
|
+
try:
|
|
31
|
+
text = (repo / path).read_text(encoding="utf-8")
|
|
32
|
+
except OSError:
|
|
33
|
+
continue
|
|
34
|
+
match = re.search(r"(?m)^\s*module\s+(\S+)\s*$", text)
|
|
35
|
+
if match:
|
|
36
|
+
parent = PurePosixPath(path).parent
|
|
37
|
+
modules[match[1]] = "" if str(parent) == "." else str(parent)
|
|
38
|
+
return modules
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ImportResolver:
|
|
42
|
+
def __init__(self, repo: Path, files=None, suffix_map=None):
|
|
43
|
+
self.repo = repo
|
|
44
|
+
self.suffix_map = suffix_languages() if suffix_map is None else suffix_map
|
|
45
|
+
self.files = repository_files(repo) if files is None else files
|
|
46
|
+
self.suffix_files = {}
|
|
47
|
+
for path in sorted(self.files):
|
|
48
|
+
parts = path.split("/")
|
|
49
|
+
for start in range(len(parts)):
|
|
50
|
+
self.suffix_files.setdefault("/".join(parts[start:]), []).append(path)
|
|
51
|
+
self.diagnostics = []
|
|
52
|
+
self.include_basis = {}
|
|
53
|
+
self.go_modules = go_modules(repo, self.files)
|
|
54
|
+
self.python_roots = [""]
|
|
55
|
+
# Locate import roots from package ancestry rather than assuming that
|
|
56
|
+
# top-level packages live at the repository root (src/ is common).
|
|
57
|
+
for path in self.files:
|
|
58
|
+
if not path.endswith("/__init__.py"):
|
|
59
|
+
continue
|
|
60
|
+
package = PurePosixPath(path).parent
|
|
61
|
+
top = package
|
|
62
|
+
while str(top.parent) != "." and str(top.parent / "__init__.py") in self.files:
|
|
63
|
+
top = top.parent
|
|
64
|
+
root = "" if str(top.parent) == "." else str(top.parent) + "/"
|
|
65
|
+
if root not in self.python_roots:
|
|
66
|
+
self.python_roots.append(root)
|
|
67
|
+
|
|
68
|
+
def python_file(self, module: str) -> str | None:
|
|
69
|
+
stem = module.replace(".", "/")
|
|
70
|
+
for root in self.python_roots:
|
|
71
|
+
for candidate in (root + stem + "/__init__.py", root + stem + ".py"):
|
|
72
|
+
if candidate in self.files:
|
|
73
|
+
return candidate
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def python_local(self, module: str) -> bool:
|
|
77
|
+
top = module.split(".")[0]
|
|
78
|
+
return any(p.startswith(root + top + "/") or p == root + top + ".py"
|
|
79
|
+
for root in self.python_roots for p in self.files)
|
|
80
|
+
|
|
81
|
+
def _python(self, path: str, text: str) -> list[tuple[str, str | None]]:
|
|
82
|
+
try:
|
|
83
|
+
statement = ast.parse(text).body[0]
|
|
84
|
+
except (SyntaxError, IndexError):
|
|
85
|
+
return [("unresolved", None)]
|
|
86
|
+
if isinstance(statement, ast.Import):
|
|
87
|
+
modules = [alias.name for alias in statement.names]
|
|
88
|
+
relative = False
|
|
89
|
+
elif isinstance(statement, ast.ImportFrom):
|
|
90
|
+
base = statement.module or ""
|
|
91
|
+
relative = bool(statement.level)
|
|
92
|
+
if relative:
|
|
93
|
+
parent = list(PurePosixPath(path).parent.parts)
|
|
94
|
+
if parent and parent[0] == "src" and "src/" in self.python_roots:
|
|
95
|
+
parent = parent[1:]
|
|
96
|
+
if statement.level > len(parent):
|
|
97
|
+
return [("unresolved", None)]
|
|
98
|
+
base = ".".join(parent[:len(parent) - statement.level + 1] + ([base] if base else []))
|
|
99
|
+
modules = []
|
|
100
|
+
for alias in statement.names:
|
|
101
|
+
child = base + "." + alias.name if base else alias.name
|
|
102
|
+
# An imported name can be a submodule or a symbol of the base.
|
|
103
|
+
child_stem = child.replace(".", "/")
|
|
104
|
+
namespace_child = any(p.startswith(root + child_stem + "/")
|
|
105
|
+
for root in self.python_roots for p in self.files)
|
|
106
|
+
modules.append(child if self.python_file(child) or namespace_child else base)
|
|
107
|
+
else:
|
|
108
|
+
return [("unresolved", None)]
|
|
109
|
+
results = []
|
|
110
|
+
for module in modules:
|
|
111
|
+
target = self.python_file(module)
|
|
112
|
+
# Scripts outside packages can import adjacent modules when run
|
|
113
|
+
# directly. Keep this lookup repository bounded too.
|
|
114
|
+
parent = PurePosixPath(path).parent
|
|
115
|
+
if not relative and str(parent / "__init__.py") not in self.files:
|
|
116
|
+
stem = str(parent / module.replace(".", "/"))
|
|
117
|
+
target = next((p for p in (stem + ".py", stem + "/__init__.py") if p in self.files), target)
|
|
118
|
+
if target:
|
|
119
|
+
results.append(("resolved", target))
|
|
120
|
+
elif relative or self.python_local(module):
|
|
121
|
+
results.append(("unresolved", None))
|
|
122
|
+
else:
|
|
123
|
+
results.append(("external", module))
|
|
124
|
+
return list(dict.fromkeys(results))
|
|
125
|
+
|
|
126
|
+
def resolve(self, path: str, expression: str) -> list[tuple[str, str | None]]:
|
|
127
|
+
text = (expression or "").strip()
|
|
128
|
+
suffix = PurePosixPath(path).suffix.lower()
|
|
129
|
+
language = self.suffix_map.get(suffix)
|
|
130
|
+
suffix = {"c": ".c", "cpp": ".c", "ruby": ".rb", "php": ".php"}.get(language, suffix)
|
|
131
|
+
if suffix == ".py":
|
|
132
|
+
return self._python(path, text)
|
|
133
|
+
if suffix in {".c", ".h", ".cc", ".cpp", ".hpp", ".cxx", ".hh"}:
|
|
134
|
+
match = re.fullmatch(r'#\s*include\s*([<"])([^>"]+)[>"]\s*', text)
|
|
135
|
+
if not match:
|
|
136
|
+
return [("unresolved", None)]
|
|
137
|
+
delimiter, token = match.groups()
|
|
138
|
+
local = canonical_repo_path(str(PurePosixPath(path).parent / token))
|
|
139
|
+
if delimiter == '"' and local in self.files:
|
|
140
|
+
return [("resolved", local)]
|
|
141
|
+
matches = self.suffix_files.get(token, [])
|
|
142
|
+
if delimiter == '"' and not matches:
|
|
143
|
+
matches = self.suffix_files.get(PurePosixPath(token).name, [])
|
|
144
|
+
if delimiter == "<":
|
|
145
|
+
# A basename alone supplies no evidence of an include search
|
|
146
|
+
# root. In particular, repository compatibility/test headers
|
|
147
|
+
# must not shadow standard or third-party library headers.
|
|
148
|
+
if token.split("/", 1)[0] in SYSTEM_INCLUDE_PREFIXES:
|
|
149
|
+
return [("external", token)]
|
|
150
|
+
if len(matches) > 1:
|
|
151
|
+
self.diagnostics.append(dict(path=path, code="ambiguous_include",
|
|
152
|
+
expression=text, candidates=matches,
|
|
153
|
+
message=f"multiple repository candidates for <{token}>: {', '.join(matches)}"))
|
|
154
|
+
return [("unresolved", None)]
|
|
155
|
+
if "/" not in token:
|
|
156
|
+
return [("external", token)]
|
|
157
|
+
if len(matches) == 1:
|
|
158
|
+
prefix = str(PurePosixPath(token).parent)
|
|
159
|
+
self.include_basis[(path, text)] = {
|
|
160
|
+
"kind": "repository_directory_prefix", "prefix": prefix,
|
|
161
|
+
"include_root": matches[0][:-len(token)].rstrip("/") or ".",
|
|
162
|
+
}
|
|
163
|
+
return [("resolved", matches[0])]
|
|
164
|
+
return [("external", token)]
|
|
165
|
+
if len(matches) == 1:
|
|
166
|
+
return [("resolved", matches[0])]
|
|
167
|
+
return [("unresolved", None)]
|
|
168
|
+
token = import_token(text, suffix, self.suffix_map)
|
|
169
|
+
if not token:
|
|
170
|
+
return [("unresolved", None)]
|
|
171
|
+
if suffix in {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"}:
|
|
172
|
+
if not token.startswith("."):
|
|
173
|
+
return [("external", token)]
|
|
174
|
+
stem = canonical_repo_path(str(PurePosixPath(path).parent / token))
|
|
175
|
+
if stem:
|
|
176
|
+
extensions = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts")
|
|
177
|
+
candidates = [stem] + [stem + ext for ext in extensions] + [stem + "/index" + ext for ext in extensions]
|
|
178
|
+
# TypeScript source commonly imports the emitted JS filename.
|
|
179
|
+
replacements = {".js": (".ts", ".tsx"), ".mjs": (".mts",), ".cjs": (".cts",)}
|
|
180
|
+
for emitted, sources in replacements.items():
|
|
181
|
+
if stem.endswith(emitted):
|
|
182
|
+
candidates.extend(stem[:-len(emitted)] + ext for ext in sources)
|
|
183
|
+
for candidate in candidates:
|
|
184
|
+
if candidate in self.files:
|
|
185
|
+
return [("resolved", candidate)]
|
|
186
|
+
if suffix == ".rb":
|
|
187
|
+
local = text.startswith("require_relative")
|
|
188
|
+
stem = canonical_repo_path(str(PurePosixPath(path).parent / token)) if local else token
|
|
189
|
+
for candidate in (stem, (stem or "") + ".rb"):
|
|
190
|
+
if candidate in self.files:
|
|
191
|
+
return [("resolved", candidate)]
|
|
192
|
+
return [("unresolved", None)] if local else [("external", token)]
|
|
193
|
+
if suffix in {".java", ".kt", ".kts", ".cs", ".php", ".swift"}:
|
|
194
|
+
# Class imports can include a member; try the owning file too.
|
|
195
|
+
parts = token.replace("\\", ".").rstrip(".*").split(".")
|
|
196
|
+
extensions = {".kts": (".kt", ".kts")}.get(suffix, (suffix,))
|
|
197
|
+
for end in range(len(parts), 0, -1):
|
|
198
|
+
stem = "/".join(parts[:end])
|
|
199
|
+
matches = sorted(p for p in self.files if any(
|
|
200
|
+
p == stem + ext or p.endswith("/" + stem + ext) for ext in extensions))
|
|
201
|
+
if matches:
|
|
202
|
+
return [("resolved", matches[0])] if len(matches) == 1 else [("unresolved", None)]
|
|
203
|
+
return [("external", token)]
|
|
204
|
+
if suffix == ".go":
|
|
205
|
+
# A package import maps to every non-test Go source in its
|
|
206
|
+
# directory. Exploding the declaration preserves real file
|
|
207
|
+
# identities instead of inventing one representative file.
|
|
208
|
+
for module, root in sorted(self.go_modules.items(), key=lambda row: len(row[0]), reverse=True):
|
|
209
|
+
if token != module and not token.startswith(module + "/"):
|
|
210
|
+
continue
|
|
211
|
+
relative = token[len(module):].lstrip("/")
|
|
212
|
+
directory = PurePosixPath(root) / relative if relative else PurePosixPath(root)
|
|
213
|
+
prefix = "" if str(directory) == "." else str(directory).rstrip("/") + "/"
|
|
214
|
+
targets = sorted(path for path in self.files if path.startswith(prefix)
|
|
215
|
+
and "/" not in path[len(prefix):] and path.endswith(".go")
|
|
216
|
+
and not path.endswith("_test.go"))
|
|
217
|
+
return [("resolved", path) for path in targets] or [("unresolved", None)]
|
|
218
|
+
return [("external", token)]
|
|
219
|
+
if suffix == ".rs":
|
|
220
|
+
parts = token.split("::")
|
|
221
|
+
if parts[0] not in {"crate", "self", "super"}:
|
|
222
|
+
return [("external", token)]
|
|
223
|
+
base = "src" if parts[0] == "crate" else str(PurePosixPath(path).parent)
|
|
224
|
+
if parts[0] == "super":
|
|
225
|
+
base = str(PurePosixPath(base).parent)
|
|
226
|
+
for end in range(len(parts), 1, -1):
|
|
227
|
+
stem = canonical_repo_path(str(PurePosixPath(base).joinpath(*parts[1:end])))
|
|
228
|
+
for candidate in ((stem or "") + ".rs", (stem or "") + "/mod.rs"):
|
|
229
|
+
if candidate in self.files:
|
|
230
|
+
return [("resolved", candidate)]
|
|
231
|
+
return [("unresolved", None)]
|
|
232
|
+
return [("unresolved", None)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def import_token(text: str, suffix: str, suffix_map=None) -> str | None:
|
|
236
|
+
"""Select syntax by source language; Python must never hit JS's `from`."""
|
|
237
|
+
if suffix == ".py":
|
|
238
|
+
try:
|
|
239
|
+
statement = ast.parse(text).body[0]
|
|
240
|
+
except (SyntaxError, IndexError):
|
|
241
|
+
return None
|
|
242
|
+
if isinstance(statement, ast.ImportFrom):
|
|
243
|
+
return "." * statement.level + (statement.module or "")
|
|
244
|
+
if isinstance(statement, ast.Import) and len(statement.names) == 1:
|
|
245
|
+
return statement.names[0].name
|
|
246
|
+
return None
|
|
247
|
+
language = (suffix_languages() if suffix_map is None else suffix_map).get(suffix)
|
|
248
|
+
suffix = {"ruby": ".rb", "php": ".php"}.get(language, suffix)
|
|
249
|
+
patterns = {
|
|
250
|
+
".go": r'^import\s+(?:(?:\w+|\.)\s+)?["`]?([^"`\s]+)["`]?$',
|
|
251
|
+
".java": r"^import\s+(?:static\s+)?([\w.*]+)\s*;?$",
|
|
252
|
+
".kt": r"^import\s+([\w.*]+)(?:\s+as\s+\w+)?$",
|
|
253
|
+
".kts": r"^import\s+([\w.*]+)(?:\s+as\s+\w+)?$",
|
|
254
|
+
".cs": r"^(?:global\s+)?using\s+(?:static\s+)?(?:\w+\s*=\s*)?([\w.]+)\s*;?$",
|
|
255
|
+
".rb": r'''^(?:require_relative|require)\s*\(?\s*["']([^"']+)["']''',
|
|
256
|
+
".rs": r"^use\s+([\w:]+)(?:\s+as\s+\w+)?\s*;?$",
|
|
257
|
+
".php": r"^use\s+(?:function\s+|const\s+)?([\w\\]+)(?:\s+as\s+\w+)?\s*;?$",
|
|
258
|
+
".swift": r"^import\s+(?:(?:class|struct|func|enum|protocol|var|let|typealias)\s+)?([\w.]+)$",
|
|
259
|
+
}
|
|
260
|
+
pattern = patterns.get(suffix, r'''(?:require\s*\(\s*|from\s*|import\s*)["']([^"']+)["']''')
|
|
261
|
+
match = re.search(pattern, text)
|
|
262
|
+
if match:
|
|
263
|
+
return next((g for g in match.groups() if g), None)
|
|
264
|
+
return text if text and not re.search(r"[\s()#]", text) else None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def resolve_declarations(repo: Path, nodes: list[dict], edges: list[dict], *, files=None, diagnostics=None, suffix_map=None) -> tuple[list[dict], list[dict]]:
|
|
268
|
+
resolver = ImportResolver(repo, files, suffix_map)
|
|
269
|
+
known = {n["id"] for n in nodes}
|
|
270
|
+
result = []
|
|
271
|
+
for edge in edges:
|
|
272
|
+
if edge.get("kind") != "import" or edge.get("backend") != "joern":
|
|
273
|
+
result.append(edge)
|
|
274
|
+
continue
|
|
275
|
+
for index, (status, target) in enumerate(resolver.resolve(edge.get("path") or "", edge.get("expression") or "")):
|
|
276
|
+
target_id = (file_id(target, resolver.files) if status == "resolved" else external_id(target)) if target else None
|
|
277
|
+
if target_id and target_id not in known:
|
|
278
|
+
known.add(target_id)
|
|
279
|
+
nodes.append({"id": target_id, "kind": "file" if status == "resolved" else "external",
|
|
280
|
+
"path": target if status == "resolved" else None,
|
|
281
|
+
"name": target, "start_line": None, "end_line": None,
|
|
282
|
+
"backend": "joern", **({"external": True} if status == "external" else {})})
|
|
283
|
+
basis = resolver.include_basis.get((edge.get("path") or "", (edge.get("expression") or "").strip()))
|
|
284
|
+
result.append({**edge, "id": edge["id"] + f"/{index}", "status": status, "target": target_id,
|
|
285
|
+
**({"resolution_basis": basis} if basis else {})})
|
|
286
|
+
if diagnostics is not None:
|
|
287
|
+
diagnostics.extend(resolver.diagnostics)
|
|
288
|
+
return nodes, result
|