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,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
|