codebase-explorer 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbe/__init__.py +9 -0
- cbe/__main__.py +4 -0
- cbe/aggregation.py +183 -0
- cbe/api_completion.py +410 -0
- cbe/batching.py +283 -0
- cbe/catalog_query.py +376 -0
- cbe/checks.py +390 -0
- cbe/cli.py +434 -0
- cbe/concrete_facts.py +321 -0
- cbe/data/LICENSE.tiktoken +30 -0
- cbe/data/o200k_base.tiktoken +199998 -0
- cbe/declarations.py +537 -0
- cbe/docs.py +929 -0
- cbe/generate.py +316 -0
- cbe/grammars.py +741 -0
- cbe/graph.py +857 -0
- cbe/graph_backends/__init__.py +43 -0
- cbe/graph_backends/_calls.py +262 -0
- cbe/graph_backends/_paths.py +42 -0
- cbe/graph_backends/identity.py +93 -0
- cbe/graph_backends/imports.py +256 -0
- cbe/graph_backends/joern.py +1021 -0
- cbe/graph_backends/resolution.py +288 -0
- cbe/graph_backends/toolchain.py +396 -0
- cbe/grounding.py +57 -0
- cbe/host_run.py +1752 -0
- cbe/model_settings.py +80 -0
- cbe/projection.py +305 -0
- cbe/prompts.py +316 -0
- cbe/records.py +200 -0
- cbe/skeleton.py +474 -0
- cbe/source_context.py +709 -0
- cbe/symbols.py +136 -0
- cbe/token_budget.py +68 -0
- cbe/treename.py +398 -0
- cbe/usage.py +300 -0
- cbe_install/__init__.py +11 -0
- cbe_install/cli.py +434 -0
- cbe_install/resources/agents/cbe-writer.md +16 -0
- cbe_install/resources/codebase-explorer/SKILL.md +101 -0
- cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
- cbe_install/resources/codebase-explorer/references/codex.md +37 -0
- codebase_explorer-2.2.0.dist-info/METADATA +233 -0
- codebase_explorer-2.2.0.dist-info/RECORD +47 -0
- codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
- codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
- codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/graph.py
ADDED
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
"""Standard ``cbe-graph/1`` analyzer: build and query source-linked code graphs.
|
|
2
|
+
|
|
3
|
+
This module owns the public graph contract consumed by the generation pipeline
|
|
4
|
+
and the ``cbe graph`` CLI. It selects open-source backends, merges their
|
|
5
|
+
fragments into one node/edge graph keyed by repo-relative POSIX paths, and
|
|
6
|
+
exposes a pure query over an already-built graph.
|
|
7
|
+
|
|
8
|
+
Design rules:
|
|
9
|
+
|
|
10
|
+
* It never re-implements a language analyzer. Joern provides call, control
|
|
11
|
+
flow, native REACHING_DEF definition-use edges and import declarations;
|
|
12
|
+
dependency-cruiser and Grimp resolve JS/TS and Python imports to
|
|
13
|
+
files/modules.
|
|
14
|
+
* A missing or failed backend is an explicit capability/diagnostic, never a
|
|
15
|
+
silent empty success: if nothing could run, ``analyze_graph`` raises.
|
|
16
|
+
* Every node/edge that anchors real source carries a repo-relative ``path`` and
|
|
17
|
+
a resolution ``status`` from ``resolved|ambiguous|unresolved|external|
|
|
18
|
+
unsupported``. Unresolved targets keep the raw expression; ambiguous calls
|
|
19
|
+
keep their candidate list.
|
|
20
|
+
* Provenance binds the analyzed source and the relevant build/resolution
|
|
21
|
+
configuration. If either changes during the build the graph is marked
|
|
22
|
+
unstable so a downstream consumer can refuse it.
|
|
23
|
+
* Coordinates and provenance come from the analyzer. Joern reports 1-based
|
|
24
|
+
lines and 1-based UTF-16 columns; this module stores lines as given and
|
|
25
|
+
leaves byte/char mapping to the chunk layer. Raw analyzer ids are kept on
|
|
26
|
+
``analyzer_id``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
from pathlib import Path, PurePosixPath
|
|
36
|
+
from typing import Any
|
|
37
|
+
from collections.abc import Iterable
|
|
38
|
+
from datetime import datetime, timezone
|
|
39
|
+
|
|
40
|
+
from cbe.grammars import (
|
|
41
|
+
ContextError,
|
|
42
|
+
DEFAULT_EXCLUDE_DIR_NAMES,
|
|
43
|
+
GraphSourceMismatch,
|
|
44
|
+
_validate_relative,
|
|
45
|
+
sha256_bytes,
|
|
46
|
+
suffix_languages,
|
|
47
|
+
)
|
|
48
|
+
from cbe.graph_backends._paths import canonical_repo_path
|
|
49
|
+
from cbe.graph_backends.identity import repository_files, validate_identities
|
|
50
|
+
from cbe.graph_backends.resolution import resolve_declarations, import_token
|
|
51
|
+
|
|
52
|
+
BACKEND_FAILURE_CODES = frozenset({"backend_error", "backend_timeout", "frontend_error", "backend_unavailable"})
|
|
53
|
+
|
|
54
|
+
SCHEMA_GRAPH = "cbe-graph/1"
|
|
55
|
+
|
|
56
|
+
#: Build/module-resolution files that change what an analyzer sees.
|
|
57
|
+
GRAPH_CONFIG_NAMES = frozenset({
|
|
58
|
+
"compile_commands.json", "CMakeLists.txt", "Makefile", "tsconfig.json",
|
|
59
|
+
"package.json", "pyproject.toml", "go.mod", "go.sum", "pom.xml",
|
|
60
|
+
"build.gradle", "settings.gradle", "Cargo.toml", "composer.json",
|
|
61
|
+
"Gemfile", "Package.swift", "setup.py", "setup.cfg", "compile_flags.txt",
|
|
62
|
+
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
|
|
63
|
+
"Cargo.lock", "composer.lock", "Gemfile.lock", "Package.resolved",
|
|
64
|
+
"gradle.properties", "gradle.lockfile", "CMakePresets.json", "CMakeUserPresets.json",
|
|
65
|
+
})
|
|
66
|
+
GRAPH_CONFIG_SUFFIXES = (".csproj", ".sln", ".gemspec", ".cmake", ".mk")
|
|
67
|
+
|
|
68
|
+
GRAPH_KINDS = frozenset({"import", "call", "cfg", "dataflow"})
|
|
69
|
+
_CAPABILITY_RANK = {"resolved": 6, "partial": 5, "empty": 4, "not_requested": 4, "unsupported": 3,
|
|
70
|
+
"failed": 2, "unavailable": 1}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GraphError(ValueError):
|
|
74
|
+
"""Raised when no graph could be produced or an input is invalid.
|
|
75
|
+
|
|
76
|
+
A failed build may still have written an auditable, non-authoritative graph.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, *, graph: dict | None = None):
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.graph = graph
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_sources(repo, languages: list[str] | None = None, *, suffix_map=None) -> list[dict]:
|
|
85
|
+
"""Return ``[{path, language, sha256}]`` for source files under ``repo``."""
|
|
86
|
+
|
|
87
|
+
suffix_map = suffix_languages() if suffix_map is None else suffix_map
|
|
88
|
+
wanted = set(languages) if languages else None
|
|
89
|
+
manifest: list[dict] = []
|
|
90
|
+
repo = repo.resolve()
|
|
91
|
+
for rel in sorted(repository_files(repo)):
|
|
92
|
+
language = suffix_map.get(os.path.splitext(rel)[1].lower())
|
|
93
|
+
if language is None or (wanted is not None and language not in wanted):
|
|
94
|
+
continue
|
|
95
|
+
with (repo / rel).open("rb") as handle:
|
|
96
|
+
digest = hashlib.sha256(handle.read()).hexdigest()
|
|
97
|
+
manifest.append({"path": rel, "language": language, "sha256": digest})
|
|
98
|
+
manifest.sort(key=lambda entry: entry["path"])
|
|
99
|
+
return manifest
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def discover_config(repo) -> list[dict]:
|
|
103
|
+
"""Return ``[{path, sha256}]`` for build/module-resolution files."""
|
|
104
|
+
|
|
105
|
+
excluded = DEFAULT_EXCLUDE_DIR_NAMES
|
|
106
|
+
repo = os.path.abspath(str(repo))
|
|
107
|
+
found: list[dict] = []
|
|
108
|
+
for dirpath, dirnames, filenames in os.walk(repo, topdown=True, followlinks=False):
|
|
109
|
+
dirnames.sort()
|
|
110
|
+
dirnames[:] = [name for name in dirnames if name not in excluded]
|
|
111
|
+
for filename in sorted(filenames):
|
|
112
|
+
if filename not in GRAPH_CONFIG_NAMES and not filename.endswith(GRAPH_CONFIG_SUFFIXES) \
|
|
113
|
+
and not (filename.startswith("tsconfig.") and filename.endswith(".json")):
|
|
114
|
+
continue
|
|
115
|
+
full = os.path.join(dirpath, filename)
|
|
116
|
+
if os.path.islink(full) or not os.path.isfile(full):
|
|
117
|
+
continue
|
|
118
|
+
rel = os.path.relpath(full, repo).replace(os.sep, "/")
|
|
119
|
+
with open(full, "rb") as handle:
|
|
120
|
+
digest = hashlib.sha256(handle.read()).hexdigest()
|
|
121
|
+
found.append({"path": rel, "sha256": digest})
|
|
122
|
+
found.sort(key=lambda entry: entry["path"])
|
|
123
|
+
return found
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def validate_graph_provenance(repo, graph: dict) -> None:
|
|
127
|
+
"""Reject stale analysis inputs before a graph enters a reading task.
|
|
128
|
+
|
|
129
|
+
External graphs without provenance retain their source-manifest contract;
|
|
130
|
+
their configuration identity is unknown. Our builds additionally bind the
|
|
131
|
+
complete set of registered source files and recognized configuration files.
|
|
132
|
+
"""
|
|
133
|
+
from pathlib import Path
|
|
134
|
+
|
|
135
|
+
provenance = graph.get("provenance")
|
|
136
|
+
if provenance is None:
|
|
137
|
+
return
|
|
138
|
+
if not isinstance(provenance, dict):
|
|
139
|
+
raise GraphError("graph provenance must be an object")
|
|
140
|
+
for flag in ("source_stable", "config_stable"):
|
|
141
|
+
if flag in provenance and provenance[flag] is not True:
|
|
142
|
+
raise GraphError(f"graph build was unstable or malformed ({flag}); rebuild the graph")
|
|
143
|
+
repo = Path(repo).resolve()
|
|
144
|
+
|
|
145
|
+
def identities(entries, label: str) -> dict[str, str]:
|
|
146
|
+
if not isinstance(entries, list):
|
|
147
|
+
raise GraphError(f"graph {label} must be a manifest list")
|
|
148
|
+
result = {}
|
|
149
|
+
for entry in entries:
|
|
150
|
+
if not isinstance(entry, dict):
|
|
151
|
+
raise GraphError(f"malformed graph {label} entry")
|
|
152
|
+
path, digest = entry.get("path"), entry.get("sha256")
|
|
153
|
+
if not isinstance(path, str) or canonical_repo_path(path) != path \
|
|
154
|
+
or not isinstance(digest, str) or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
|
155
|
+
raise GraphError(f"malformed graph {label} identity")
|
|
156
|
+
if path in result:
|
|
157
|
+
raise GraphError(f"duplicate graph {label} path: {path}")
|
|
158
|
+
result[path] = digest
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
checks = []
|
|
162
|
+
if "config_manifest" in provenance:
|
|
163
|
+
checks.append(("configuration", provenance["config_manifest"], discover_config(repo)))
|
|
164
|
+
if provenance.get("source_manifest_scope") == "registered_sources":
|
|
165
|
+
checks.append(("source", graph.get("source_manifest"), discover_sources(repo)))
|
|
166
|
+
for label, expected_entries, actual_entries in checks:
|
|
167
|
+
expected, actual = identities(expected_entries, label), identities(actual_entries, label)
|
|
168
|
+
if expected != actual:
|
|
169
|
+
changed = sorted(path for path in expected.keys() | actual.keys()
|
|
170
|
+
if expected.get(path) != actual.get(path))
|
|
171
|
+
raise GraphError(f"graph {label} drift: {', '.join(changed[:10])}; rebuild the graph")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _graph_source_anchors(payload: dict[str, Any]):
|
|
175
|
+
"""Yield every node/edge object that could anchor repo source."""
|
|
176
|
+
|
|
177
|
+
for key in ("nodes", "edges"):
|
|
178
|
+
for item in payload.get(key) or []:
|
|
179
|
+
if isinstance(item, dict):
|
|
180
|
+
yield item
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def check_graph_source(repo: Path, graph: dict[str, Any], frozen_hashes: dict[str, str]) -> None:
|
|
185
|
+
"""Raise :class:`ContextError` unless ``graph`` can be evidence for the frozen source.
|
|
186
|
+
|
|
187
|
+
Provenance must be stable and current. Every manifest entry must name a
|
|
188
|
+
repository file whose bytes match: ``frozen_hashes`` (path to sha256) is the
|
|
189
|
+
authority for frozen files, so a file changed between two reads cannot make
|
|
190
|
+
a stale chunk set look consistent; other (boundary) files are read from
|
|
191
|
+
disk. Every node or edge that anchors existing repository source must be in
|
|
192
|
+
the manifest, so a graph cannot mix snapshots. Nodes without a repository
|
|
193
|
+
path stay external and a valid empty graph stays valid.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
validate_graph_provenance(repo, graph)
|
|
198
|
+
except (GraphError, OSError) as exc:
|
|
199
|
+
raise ContextError("graph_provenance_mismatch", str(exc)) from exc
|
|
200
|
+
manifest = graph.get("source_manifest")
|
|
201
|
+
if not isinstance(manifest, list):
|
|
202
|
+
raise ContextError("graph_manifest_missing",
|
|
203
|
+
"graph has no source_manifest; cannot prove it matches the source")
|
|
204
|
+
manifested: set[str] = set()
|
|
205
|
+
for entry in manifest:
|
|
206
|
+
if not isinstance(entry, dict) or "path" not in entry or "sha256" not in entry:
|
|
207
|
+
raise ContextError("graph_manifest_missing", f"malformed source_manifest entry: {entry!r}")
|
|
208
|
+
raw_rel, expected = str(entry["path"]), str(entry["sha256"])
|
|
209
|
+
try:
|
|
210
|
+
rel = _validate_relative(repo, raw_rel)
|
|
211
|
+
except ContextError as exc:
|
|
212
|
+
raise ContextError(
|
|
213
|
+
"graph_source_mismatch",
|
|
214
|
+
f"graph manifest path is not a valid repo path: {raw_rel}: {exc.message}",
|
|
215
|
+
path=raw_rel,
|
|
216
|
+
reason="invalid_path",
|
|
217
|
+
) from exc
|
|
218
|
+
manifested.add(rel)
|
|
219
|
+
if rel in frozen_hashes:
|
|
220
|
+
if frozen_hashes[rel] != expected:
|
|
221
|
+
raise GraphSourceMismatch(rel, expected, frozen_hashes[rel],
|
|
222
|
+
"content hash differs from current source")
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
actual = sha256_bytes((repo / PurePosixPath(rel)).read_bytes())
|
|
226
|
+
except OSError:
|
|
227
|
+
raise GraphSourceMismatch(rel, expected, None,
|
|
228
|
+
"file present in graph manifest is missing on disk") from None
|
|
229
|
+
if actual != expected:
|
|
230
|
+
raise GraphSourceMismatch(rel, expected, actual,
|
|
231
|
+
"boundary source drifted: content hash differs from current source")
|
|
232
|
+
unmanifested: set[str] = set()
|
|
233
|
+
# A path that is not a non-empty string cannot name repository source; such
|
|
234
|
+
# anchors stay external, as an unknown path does.
|
|
235
|
+
anchored = {path for anchor in _graph_source_anchors(graph)
|
|
236
|
+
if isinstance(path := anchor.get("path"), str) and path}
|
|
237
|
+
for raw_path in anchored:
|
|
238
|
+
try:
|
|
239
|
+
rel = _validate_relative(repo, raw_path)
|
|
240
|
+
except ContextError:
|
|
241
|
+
continue
|
|
242
|
+
if rel not in manifested and (repo / rel).exists():
|
|
243
|
+
unmanifested.add(rel)
|
|
244
|
+
if unmanifested:
|
|
245
|
+
listed = ", ".join(sorted(unmanifested)[:10])
|
|
246
|
+
raise ContextError(
|
|
247
|
+
"graph_unmanifested_source",
|
|
248
|
+
f"graph anchors repo source not covered by source_manifest: {listed}",
|
|
249
|
+
paths=sorted(unmanifested),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _prune_graph(graph: dict[str, Any] | None, selected: set[str]) -> dict[str, Any] | None:
|
|
255
|
+
if graph is None:
|
|
256
|
+
return None
|
|
257
|
+
nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
|
|
258
|
+
edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
|
259
|
+
by_id = {node.get("id"): node for node in nodes}
|
|
260
|
+
|
|
261
|
+
kept_ids: set[str] = {node.get("id") for node in nodes if node.get("path") in selected}
|
|
262
|
+
kept_edges: list[dict[str, Any]] = []
|
|
263
|
+
for edge in edges:
|
|
264
|
+
source = edge.get("source")
|
|
265
|
+
target = edge.get("target")
|
|
266
|
+
source_node = by_id.get(source)
|
|
267
|
+
target_node = by_id.get(target)
|
|
268
|
+
touches = (
|
|
269
|
+
(source_node is not None and source_node.get("path") in selected)
|
|
270
|
+
or (target_node is not None and target_node.get("path") in selected)
|
|
271
|
+
)
|
|
272
|
+
if touches:
|
|
273
|
+
kept_edges.append(edge)
|
|
274
|
+
if source is not None:
|
|
275
|
+
kept_ids.add(source)
|
|
276
|
+
if target is not None:
|
|
277
|
+
kept_ids.add(target)
|
|
278
|
+
|
|
279
|
+
kept_nodes = [node for node in nodes if node.get("id") in kept_ids]
|
|
280
|
+
pruned: dict[str, Any] = {
|
|
281
|
+
key: value for key, value in graph.items() if key not in {"nodes", "edges", "source_manifest", "quality"}
|
|
282
|
+
}
|
|
283
|
+
pruned["nodes"] = kept_nodes
|
|
284
|
+
pruned["edges"] = kept_edges
|
|
285
|
+
# Keep the full analysis manifest. A local slice may reference files
|
|
286
|
+
# outside the selection because their nodes are boundary endpoints; a
|
|
287
|
+
# trimmed manifest would misrepresent the provenance of the whole graph.
|
|
288
|
+
pruned["source_manifest"] = list(graph.get("source_manifest", []))
|
|
289
|
+
pruned["scope"] = {
|
|
290
|
+
"paths": sorted(selected),
|
|
291
|
+
"partial": True,
|
|
292
|
+
"edge_filter": "incident_to_selected",
|
|
293
|
+
"note": "Boundary/external nodes kept; relationships outside the selection are not shown.",
|
|
294
|
+
}
|
|
295
|
+
return pruned
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def graph_quality(graph: dict, *, existing_paths: Iterable[str] | None = None) -> dict:
|
|
300
|
+
"""Pure audit of a graph and an optional repository-file snapshot.
|
|
301
|
+
|
|
302
|
+
Language is the declaration/source language (manifest, then node metadata),
|
|
303
|
+
never the target language. Null endpoints denote absent external/unresolved
|
|
304
|
+
targets and are not dangling IDs. With no file snapshot, existence is unknown
|
|
305
|
+
rather than silently reported as zero; persisted build audits retain it.
|
|
306
|
+
"""
|
|
307
|
+
nodes, edges = graph.get("nodes", []), graph.get("edges", [])
|
|
308
|
+
by_id = {n.get("id"): n for n in nodes}
|
|
309
|
+
manifest = {row["path"]: row.get("language", "unknown")
|
|
310
|
+
for row in graph.get("source_manifest", [])}
|
|
311
|
+
capabilities = graph.get("capabilities", [])
|
|
312
|
+
languages = set(manifest.values()) | {c.get("language") or "unknown" for c in capabilities}
|
|
313
|
+
statuses = ("resolved", "ambiguous", "unresolved", "external", "unsupported")
|
|
314
|
+
not_exported = {(c.get("language") or "unknown", "dataflow") for c in capabilities
|
|
315
|
+
if c.get("graph") == "dataflow" and c.get("status") == "not_requested"}
|
|
316
|
+
counts = {(language, kind): dict.fromkeys(statuses, 0)
|
|
317
|
+
for language in languages for kind in sorted(GRAPH_KINDS)
|
|
318
|
+
if (language, kind) not in not_exported} # a row of zeros would claim "no dataflow edges"
|
|
319
|
+
dangling = []
|
|
320
|
+
for edge in edges:
|
|
321
|
+
source = by_id.get(edge.get("source"), {})
|
|
322
|
+
edge_path, source_path = edge.get("path"), source.get("path")
|
|
323
|
+
language = (manifest.get(edge_path) if isinstance(edge_path, str) else None)
|
|
324
|
+
language = (language or (manifest.get(source_path) if isinstance(source_path, str) else None)
|
|
325
|
+
or edge.get("language") or source.get("language") or "unknown")
|
|
326
|
+
key = (language, edge.get("kind") or "unknown")
|
|
327
|
+
row = counts.setdefault(key, dict.fromkeys(statuses, 0))
|
|
328
|
+
status = edge.get("status") or "unknown"
|
|
329
|
+
row[status] = row.get(status, 0) + 1
|
|
330
|
+
missing = [edge[endpoint] for endpoint in ("source", "target")
|
|
331
|
+
if edge.get(endpoint) is not None and edge[endpoint] not in by_id]
|
|
332
|
+
if missing:
|
|
333
|
+
dangling.append({"id": edge.get("id"), "missing_endpoints": missing})
|
|
334
|
+
anchored = [n for n in nodes if isinstance(n.get("path"), str)]
|
|
335
|
+
outside = [n for n in anchored if n["path"] not in manifest]
|
|
336
|
+
missing_files = None
|
|
337
|
+
if existing_paths is not None:
|
|
338
|
+
existing = set(existing_paths)
|
|
339
|
+
missing_files = [n for n in anchored if n["path"] not in existing]
|
|
340
|
+
diagnostics = graph.get("diagnostics", [])
|
|
341
|
+
no_backend = any(d.get("code") == "no_backend_completed" for d in diagnostics)
|
|
342
|
+
backend = graph.get("backend") or {}
|
|
343
|
+
successful = {"resolved", "partial", "empty", "not_requested"}
|
|
344
|
+
all_failed = bool(capabilities) and all(c.get("status") == "failed" for c in capabilities)
|
|
345
|
+
successful_component = any(c.get("status") in successful for c in backend.get("components", []))
|
|
346
|
+
completed = (False if no_backend or backend.get("name") == "none" or (all_failed and not successful_component) else
|
|
347
|
+
True if backend.get("components") or any(
|
|
348
|
+
c.get("status") in successful for c in capabilities) else None)
|
|
349
|
+
return {
|
|
350
|
+
"edge_counts": [{"language": language, "kind": kind, "statuses": row,
|
|
351
|
+
"total": sum(row.values())}
|
|
352
|
+
for (language, kind), row in sorted(counts.items())],
|
|
353
|
+
"dangling_edges": {"count": len(dangling), "examples": dangling[:5]},
|
|
354
|
+
"nodes_outside_manifest": {"count": len(outside), "examples": [n.get("id") for n in outside[:5]]},
|
|
355
|
+
"nodes_missing_files": {"count": None if missing_files is None else len(missing_files),
|
|
356
|
+
"examples": [] if missing_files is None else [n.get("id") for n in missing_files[:5]]},
|
|
357
|
+
"file_existence_checked": existing_paths is not None,
|
|
358
|
+
"empty_graph": not nodes and not edges,
|
|
359
|
+
"backend_completed": completed,
|
|
360
|
+
"incomplete_capabilities": [dict(c) for c in capabilities
|
|
361
|
+
if c.get("status") not in {"resolved", "empty", "not_requested"}],
|
|
362
|
+
"frontend_failures": [dict(d) for d in diagnostics if d.get("code") in BACKEND_FAILURE_CODES],
|
|
363
|
+
"non_call_sites": dict(graph.get("non_call_sites", {})),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def read_graph_quality(graph: dict, *, existing_paths: Iterable[str] | None = None) -> dict:
|
|
368
|
+
"""Read the build's persisted audit; an explicit file snapshot refreshes it."""
|
|
369
|
+
if "quality" not in graph:
|
|
370
|
+
raise GraphError("this graph has no quality audit; rebuild it with this version of cbe")
|
|
371
|
+
if existing_paths is None:
|
|
372
|
+
return graph["quality"]
|
|
373
|
+
return graph_quality(graph, existing_paths=existing_paths)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
NO_DATAFLOW_MESSAGE = ("this graph was built without dataflow; "
|
|
377
|
+
"rebuild with `cbe graph build --with-dataflow`")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def built_without_dataflow(graph: object) -> bool:
|
|
381
|
+
"""True when the graph says its dataflow was not exported (every dataflow capability is not_requested)."""
|
|
382
|
+
rows = [c for c in (graph.get("capabilities") or []) if isinstance(c, dict) and c.get("graph") == "dataflow"] \
|
|
383
|
+
if isinstance(graph, dict) else []
|
|
384
|
+
return bool(rows) and all(c.get("status") == "not_requested" for c in rows)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def graph_state(graph: object, *, quality: dict | None = None) -> tuple[str, str]:
|
|
388
|
+
"""Classify availability once for prompts and quality consumers.
|
|
389
|
+
|
|
390
|
+
An explicit failed build takes precedence over unsupported capabilities.
|
|
391
|
+
A completed backend can produce an empty graph even if another capability failed.
|
|
392
|
+
"""
|
|
393
|
+
if graph is None:
|
|
394
|
+
return "absent", "no graph was provided for this run"
|
|
395
|
+
if not isinstance(graph, dict) or graph.get("schema") != SCHEMA_GRAPH:
|
|
396
|
+
found = graph.get("schema") if isinstance(graph, dict) else type(graph).__name__
|
|
397
|
+
return "unsupported", f"graph schema is {found!r}, expected 'cbe-graph/1'"
|
|
398
|
+
quality = read_graph_quality(graph) if quality is None else quality
|
|
399
|
+
if quality["backend_completed"] is False:
|
|
400
|
+
return "failed", "no backend completed; the empty graph is not authoritative"
|
|
401
|
+
nodes, edges = graph.get("nodes") or [], graph.get("edges") or []
|
|
402
|
+
if nodes or edges:
|
|
403
|
+
return "provided", f"{len(nodes)} nodes, {len(edges)} edges"
|
|
404
|
+
if quality["backend_completed"] is True:
|
|
405
|
+
return "empty", "the analyzer ran and found no relation"
|
|
406
|
+
statuses = {c.get("status") for c in quality["incomplete_capabilities"]}
|
|
407
|
+
if statuses & {"failed", "unavailable"}:
|
|
408
|
+
return "failed", "backend failed or was unavailable"
|
|
409
|
+
if "unsupported" in statuses:
|
|
410
|
+
return "unsupported", "no requested language/graph type is supported"
|
|
411
|
+
diagnostics = {row.get("code") for row in graph.get("diagnostics") or []}
|
|
412
|
+
if "no_language_requested" in diagnostics or (
|
|
413
|
+
"analyzed_languages" in graph and not graph["analyzed_languages"]):
|
|
414
|
+
return "unsupported", "no language was selected for analysis"
|
|
415
|
+
return "empty", "the analyzer ran and found no relation"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def analyze_graph(
|
|
419
|
+
repo,
|
|
420
|
+
output,
|
|
421
|
+
*,
|
|
422
|
+
backend: str = "auto",
|
|
423
|
+
languages: list[str] | None = None,
|
|
424
|
+
joern_home=None,
|
|
425
|
+
joern_heap: str | None = None,
|
|
426
|
+
depcruise_bin=None,
|
|
427
|
+
timeout: int = 300,
|
|
428
|
+
max_methods: int | None = None,
|
|
429
|
+
max_reaching_defs: int | None = None,
|
|
430
|
+
dataflow_path_methods: list[str] | None = None,
|
|
431
|
+
max_paths_per_method: int = 5,
|
|
432
|
+
with_dataflow: bool = False,
|
|
433
|
+
) -> dict:
|
|
434
|
+
"""Build a ``cbe-graph/1`` graph and write it to ``output``.
|
|
435
|
+
|
|
436
|
+
``backend`` is ``auto``, ``joern``, or ``imports``. Runtime artifacts (CPG,
|
|
437
|
+
export JSON, script) stay in a sibling run directory next to ``output``;
|
|
438
|
+
nothing is written into the source tree.
|
|
439
|
+
|
|
440
|
+
Dataflow (reaching definitions) is exported only on request: ``with_dataflow``,
|
|
441
|
+
or giving ``max_reaching_defs`` or ``dataflow_path_methods``, which already
|
|
442
|
+
mean the caller wants it. Without it the dataflow capability is ``not_requested``.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
from pathlib import Path
|
|
446
|
+
|
|
447
|
+
from cbe import graph_backends as backends # noqa: PLC0415
|
|
448
|
+
|
|
449
|
+
with_dataflow = bool(with_dataflow or max_reaching_defs is not None or dataflow_path_methods)
|
|
450
|
+
if max_reaching_defs is None:
|
|
451
|
+
max_reaching_defs = 50000
|
|
452
|
+
for name, value in (("timeout", timeout),
|
|
453
|
+
("max_reaching_defs", max_reaching_defs),
|
|
454
|
+
("max_paths_per_method", max_paths_per_method)):
|
|
455
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
456
|
+
raise GraphError(f"{name} must be a positive integer")
|
|
457
|
+
if max_methods is not None and (isinstance(max_methods, bool) or not isinstance(max_methods, int) or max_methods <= 0):
|
|
458
|
+
raise GraphError("max_methods must be a positive integer or None")
|
|
459
|
+
if joern_heap is not None and not re.fullmatch(r"[1-9][0-9]*[kKmMgG]", joern_heap):
|
|
460
|
+
raise GraphError("joern_heap must be a positive JVM heap size, such as 8g or 4096m")
|
|
461
|
+
repo = Path(repo).expanduser().resolve()
|
|
462
|
+
if not repo.is_dir():
|
|
463
|
+
raise GraphError(f"repository root is not a directory: {repo}")
|
|
464
|
+
output = Path(output).expanduser()
|
|
465
|
+
# Frontends can read sibling languages (for example C/C++ headers or
|
|
466
|
+
# JS/TS imports). Bind every registered source even for a language filter.
|
|
467
|
+
suffix_map = suffix_languages()
|
|
468
|
+
manifest = discover_sources(repo, suffix_map=suffix_map)
|
|
469
|
+
config_manifest = discover_config(repo)
|
|
470
|
+
detected = sorted({entry["language"] for entry in manifest})
|
|
471
|
+
requested = sorted(set(languages)) if languages else detected
|
|
472
|
+
run_dir = output.parent / (output.stem + ".run")
|
|
473
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
|
|
475
|
+
fragments: list[dict] = []
|
|
476
|
+
diagnostics: list[dict] = []
|
|
477
|
+
components: list[dict] = []
|
|
478
|
+
extra_capabilities: list[dict] = []
|
|
479
|
+
ran = False
|
|
480
|
+
|
|
481
|
+
use_joern = backend in ("auto", "joern")
|
|
482
|
+
use_imports = backend in ("auto", "imports")
|
|
483
|
+
joern_missing = False
|
|
484
|
+
|
|
485
|
+
if use_joern:
|
|
486
|
+
joern = backends.find_joern(joern_home)
|
|
487
|
+
if joern is None:
|
|
488
|
+
joern_missing = True
|
|
489
|
+
diagnostics.append({"path": None, "code": "backend_unavailable",
|
|
490
|
+
"message": "Joern CLI not found; call/cfg/dataflow/import graph skipped; "
|
|
491
|
+
+ backends.setup_hint()})
|
|
492
|
+
extra_capabilities.extend(_unavailable("joern", requested, ("call", "cfg", "dataflow", "import")))
|
|
493
|
+
else:
|
|
494
|
+
try:
|
|
495
|
+
fragment = backends.build_with_joern(
|
|
496
|
+
repo, run_dir / "joern", requested, joern, timeout,
|
|
497
|
+
joern_heap=joern_heap, java=backends.find_java(), max_methods=max_methods,
|
|
498
|
+
max_reaching_defs=max_reaching_defs, with_dataflow=with_dataflow,
|
|
499
|
+
dataflow_path_methods=dataflow_path_methods,
|
|
500
|
+
max_paths_per_method=max_paths_per_method)
|
|
501
|
+
except backends.JoernError as exc:
|
|
502
|
+
diagnostics.append({"path": None, "code": "backend_error", "message": str(exc)})
|
|
503
|
+
extra_capabilities.extend(_unavailable("joern", requested, ("call", "cfg", "dataflow")))
|
|
504
|
+
else:
|
|
505
|
+
fragments.append(fragment)
|
|
506
|
+
components.append(fragment["backend"])
|
|
507
|
+
diagnostics.extend(fragment["diagnostics"])
|
|
508
|
+
ran = ran or (bool(fragment["raw"]) and any(
|
|
509
|
+
c.get("status") in {"resolved", "partial", "empty", "not_requested"}
|
|
510
|
+
for c in fragment["capabilities"]))
|
|
511
|
+
|
|
512
|
+
# An automatic call-graph build has one failure contract when Joern is
|
|
513
|
+
# absent. Import-only analysis remains available by explicit selection.
|
|
514
|
+
if use_imports and not (backend == "auto" and joern_missing):
|
|
515
|
+
depcruise = backends.find_depcruise(depcruise_bin)
|
|
516
|
+
import_languages = [lang for lang in requested if lang in backends.DEPCRUISE_LANGUAGES]
|
|
517
|
+
if depcruise is None and import_languages:
|
|
518
|
+
diagnostics.append({"path": None, "code": "backend_unavailable",
|
|
519
|
+
"message": "dependency-cruiser not found; JS/TS import graph skipped"})
|
|
520
|
+
extra_capabilities.extend(_unavailable("dependency-cruiser", import_languages, ("import",)))
|
|
521
|
+
elif depcruise is not None and import_languages:
|
|
522
|
+
fragment = backends.build_with_depcruise(repo, run_dir / "depcruise", depcruise,
|
|
523
|
+
timeout, import_languages)
|
|
524
|
+
if fragment["nodes"] or not fragment["diagnostics"]:
|
|
525
|
+
components.append({"name": "dependency-cruiser",
|
|
526
|
+
"version": _depcruise_version(depcruise, timeout)})
|
|
527
|
+
ran = True
|
|
528
|
+
fragments.append(fragment)
|
|
529
|
+
diagnostics.extend(fragment["diagnostics"])
|
|
530
|
+
if "python" in requested:
|
|
531
|
+
fragment = backends.build_with_grimp(repo, requested, suffix_map)
|
|
532
|
+
if fragment["nodes"]:
|
|
533
|
+
components.append({"name": "grimp", "version": _grimp_version()})
|
|
534
|
+
ran = True
|
|
535
|
+
fragments.append(fragment)
|
|
536
|
+
diagnostics.extend(fragment["diagnostics"])
|
|
537
|
+
|
|
538
|
+
files = repository_files(repo)
|
|
539
|
+
nodes, edges = _merge(fragments)
|
|
540
|
+
nodes, edges = resolve_declarations(repo, nodes, edges, files=files, diagnostics=diagnostics, suffix_map=suffix_map)
|
|
541
|
+
# The merge boundary validates identities; declaration resolution above
|
|
542
|
+
# is the only authority for turning an include into a repository file.
|
|
543
|
+
nodes, edges, identity_diagnostics = validate_identities(nodes, edges, files)
|
|
544
|
+
diagnostics.extend(identity_diagnostics)
|
|
545
|
+
edges = _prefer_resolved_imports(edges, suffix_map=suffix_map)
|
|
546
|
+
capabilities = _merge_capabilities(fragments, requested, extra_capabilities)
|
|
547
|
+
for capability in capabilities:
|
|
548
|
+
if capability.get("backend") == "joern" and capability.get("graph") == "import" and capability.get("status") not in {"failed", "unsupported", "unavailable"}:
|
|
549
|
+
relevant = [e for e in edges if e.get("kind") == "import"
|
|
550
|
+
and suffix_map.get(Path(e.get("path") or "").suffix.lower()) == capability.get("language")]
|
|
551
|
+
counts = {status: sum(e.get("status") == status for e in relevant)
|
|
552
|
+
for status in ("resolved", "external", "unresolved")}
|
|
553
|
+
capability.update(counts)
|
|
554
|
+
capability["status"] = "partial" if counts["unresolved"] else ("resolved" if relevant else "empty")
|
|
555
|
+
capability["limitations"] = "; ".join(f"{n} {s}" for s, n in counts.items()) + "; static repository-bounded resolution"
|
|
556
|
+
|
|
557
|
+
if not ran:
|
|
558
|
+
diagnostics.append({"path": None, "code": "no_backend_completed",
|
|
559
|
+
"message": "no backend completed; graph is empty and not authoritative"})
|
|
560
|
+
|
|
561
|
+
source_stable = discover_sources(repo, suffix_map=suffix_map) == manifest
|
|
562
|
+
config_stable = discover_config(repo) == config_manifest
|
|
563
|
+
if not source_stable:
|
|
564
|
+
diagnostics.append({"path": None, "code": "source_changed_during_build",
|
|
565
|
+
"message": "source bytes changed while the analyzers ran; "
|
|
566
|
+
"the graph no longer matches the current source"})
|
|
567
|
+
if not config_stable:
|
|
568
|
+
diagnostics.append({"path": None, "code": "config_changed_during_build",
|
|
569
|
+
"message": "build/module-resolution configuration changed while the "
|
|
570
|
+
"analyzers ran; the graph's coverage conditions are stale"})
|
|
571
|
+
provenance = {
|
|
572
|
+
"source_stable": source_stable,
|
|
573
|
+
"config_stable": config_stable,
|
|
574
|
+
"config_manifest": config_manifest,
|
|
575
|
+
"source_manifest_scope": "registered_sources",
|
|
576
|
+
"analyzed_languages": requested,
|
|
577
|
+
"backend_components": components,
|
|
578
|
+
"analyzed_at": datetime.now(timezone.utc).isoformat(),
|
|
579
|
+
"fingerprint": _fingerprint(manifest, config_manifest, components),
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
non_call_sites = {}
|
|
583
|
+
for fragment in fragments:
|
|
584
|
+
for reason, count in fragment.get("non_call_sites", {}).items():
|
|
585
|
+
non_call_sites[reason] = non_call_sites.get(reason, 0) + count
|
|
586
|
+
|
|
587
|
+
graph = {
|
|
588
|
+
"schema": SCHEMA_GRAPH,
|
|
589
|
+
"source_manifest": manifest,
|
|
590
|
+
"backend": _backend_record(components),
|
|
591
|
+
"provenance": provenance,
|
|
592
|
+
"nodes": nodes,
|
|
593
|
+
"edges": edges,
|
|
594
|
+
"capabilities": capabilities,
|
|
595
|
+
"diagnostics": diagnostics,
|
|
596
|
+
"non_call_sites": dict(sorted(non_call_sites.items())),
|
|
597
|
+
}
|
|
598
|
+
graph = _scrub_repository_paths(graph, repo)
|
|
599
|
+
graph["quality"] = graph_quality(graph, existing_paths=files)
|
|
600
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
output.write_text(json.dumps(graph, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
602
|
+
if not ran:
|
|
603
|
+
causes = "; ".join(d["message"] for d in diagnostics if d.get("code") in BACKEND_FAILURE_CODES)
|
|
604
|
+
raise GraphError("no graph backend completed" + (f": {causes}" if causes else ""), graph=graph)
|
|
605
|
+
return graph
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _scrub_repository_paths(value, repo):
|
|
609
|
+
"""Remove the repository's absolute prefix from every graph text field."""
|
|
610
|
+
if isinstance(value, dict):
|
|
611
|
+
return {key: _scrub_repository_paths(item, repo) for key, item in value.items()}
|
|
612
|
+
if isinstance(value, list):
|
|
613
|
+
return [_scrub_repository_paths(item, repo) for item in value]
|
|
614
|
+
if isinstance(value, tuple):
|
|
615
|
+
return tuple(_scrub_repository_paths(item, repo) for item in value)
|
|
616
|
+
if isinstance(value, str):
|
|
617
|
+
root = str(repo).rstrip(os.sep)
|
|
618
|
+
return value.replace(root + os.sep, "").replace(root, ".")
|
|
619
|
+
return value
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def query_graph(
|
|
623
|
+
graph: dict,
|
|
624
|
+
*,
|
|
625
|
+
paths: list[str] | None = None,
|
|
626
|
+
kind: str | None = None,
|
|
627
|
+
direction: str = "both",
|
|
628
|
+
node_id: str | None = None,
|
|
629
|
+
) -> dict:
|
|
630
|
+
"""Select a local slice of an already-built graph. Pure; reads no source.
|
|
631
|
+
|
|
632
|
+
Paths are canonical repo-relative POSIX paths. If none of the requested
|
|
633
|
+
paths exist the result is an explicitly partial, empty slice (with the
|
|
634
|
+
unmatched paths listed), never the whole graph.
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
if not isinstance(graph, dict) or graph.get("schema") != SCHEMA_GRAPH:
|
|
638
|
+
raise GraphError("expected a cbe-graph/1 payload")
|
|
639
|
+
if kind is not None and kind not in GRAPH_KINDS:
|
|
640
|
+
raise GraphError(f"unknown graph kind {kind!r}; expected one of {sorted(GRAPH_KINDS)}")
|
|
641
|
+
if kind == "dataflow" and built_without_dataflow(graph):
|
|
642
|
+
raise GraphError(NO_DATAFLOW_MESSAGE)
|
|
643
|
+
if direction not in ("both", "in", "out"):
|
|
644
|
+
raise GraphError(f"unknown direction {direction!r}")
|
|
645
|
+
|
|
646
|
+
nodes = [node for node in graph.get("nodes", []) if isinstance(node, dict)]
|
|
647
|
+
edges = [edge for edge in graph.get("edges", []) if isinstance(edge, dict)]
|
|
648
|
+
by_id = {node.get("id"): node for node in nodes}
|
|
649
|
+
|
|
650
|
+
canonical_paths: list[str] = []
|
|
651
|
+
invalid_paths: list[str] = []
|
|
652
|
+
for raw in paths or []:
|
|
653
|
+
canonical = canonical_repo_path(raw)
|
|
654
|
+
if canonical is None:
|
|
655
|
+
invalid_paths.append(str(raw))
|
|
656
|
+
else:
|
|
657
|
+
canonical_paths.append(canonical)
|
|
658
|
+
|
|
659
|
+
selected: set[str] = set()
|
|
660
|
+
unmatched: list[str] = []
|
|
661
|
+
if node_id is not None:
|
|
662
|
+
if node_id not in by_id:
|
|
663
|
+
raise GraphError(f"node {node_id!r} is not in this graph")
|
|
664
|
+
selected.add(node_id)
|
|
665
|
+
if canonical_paths:
|
|
666
|
+
present = {node.get("path") for node in nodes}
|
|
667
|
+
selected |= {node["id"] for node in nodes if node.get("path") in set(canonical_paths)}
|
|
668
|
+
unmatched = sorted({path for path in canonical_paths if path not in present})
|
|
669
|
+
|
|
670
|
+
if kind is not None:
|
|
671
|
+
edges = [edge for edge in edges if edge.get("kind") == kind]
|
|
672
|
+
|
|
673
|
+
def incident(edge: dict) -> bool:
|
|
674
|
+
source, target = edge.get("source"), edge.get("target")
|
|
675
|
+
if direction in ("both", "out") and source in selected:
|
|
676
|
+
return True
|
|
677
|
+
if direction in ("both", "in") and target in selected:
|
|
678
|
+
return True
|
|
679
|
+
return False
|
|
680
|
+
|
|
681
|
+
filtered = bool(paths or kind or node_id or invalid_paths)
|
|
682
|
+
selection_requested = bool(paths or node_id or invalid_paths)
|
|
683
|
+
if selected:
|
|
684
|
+
kept_edges = [edge for edge in edges if incident(edge)]
|
|
685
|
+
kept_ids = set(selected) if kind is None else ({node_id} if node_id else set())
|
|
686
|
+
for edge in kept_edges:
|
|
687
|
+
if edge.get("source") is not None:
|
|
688
|
+
kept_ids.add(edge["source"])
|
|
689
|
+
if edge.get("target") is not None:
|
|
690
|
+
kept_ids.add(edge["target"])
|
|
691
|
+
kept_nodes = [node for node in nodes if node.get("id") in kept_ids]
|
|
692
|
+
elif selection_requested:
|
|
693
|
+
# A node selection was requested but matched nothing: respect the empty scope.
|
|
694
|
+
kept_edges = []
|
|
695
|
+
kept_nodes = []
|
|
696
|
+
else:
|
|
697
|
+
kept_edges = edges
|
|
698
|
+
endpoint_ids = {edge.get(endpoint) for edge in edges for endpoint in ("source", "target")}
|
|
699
|
+
kept_nodes = nodes if kind is None else [node for node in nodes if node.get("id") in endpoint_ids]
|
|
700
|
+
|
|
701
|
+
diagnostics = list(graph.get("diagnostics", []))
|
|
702
|
+
if unmatched:
|
|
703
|
+
diagnostics.append({"path": None, "code": "query_paths_not_found",
|
|
704
|
+
"message": "no graph node matches the requested path(s): "
|
|
705
|
+
+ ", ".join(unmatched[:10])})
|
|
706
|
+
if invalid_paths:
|
|
707
|
+
diagnostics.append({"path": None, "code": "query_paths_invalid",
|
|
708
|
+
"message": "query paths must be repo-relative POSIX paths: "
|
|
709
|
+
+ ", ".join(invalid_paths[:10])})
|
|
710
|
+
|
|
711
|
+
return {
|
|
712
|
+
"schema": SCHEMA_GRAPH,
|
|
713
|
+
"backend": graph.get("backend"),
|
|
714
|
+
"provenance": graph.get("provenance"),
|
|
715
|
+
"source_manifest": list(graph.get("source_manifest", [])),
|
|
716
|
+
"nodes": kept_nodes,
|
|
717
|
+
"edges": kept_edges,
|
|
718
|
+
"capabilities": graph.get("capabilities", []),
|
|
719
|
+
"diagnostics": diagnostics,
|
|
720
|
+
"scope": {
|
|
721
|
+
"paths": canonical_paths or None,
|
|
722
|
+
"kind": kind,
|
|
723
|
+
"direction": direction,
|
|
724
|
+
"node_id": node_id,
|
|
725
|
+
"partial": filtered,
|
|
726
|
+
"edge_filter": ("incident_to_selection" if selected else "empty_selection")
|
|
727
|
+
if selection_requested else ("kind_only" if kind else "none"),
|
|
728
|
+
"unmatched_paths": unmatched or None,
|
|
729
|
+
"invalid_paths": invalid_paths or None,
|
|
730
|
+
},
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# ---------------------------------------------------------------------------
|
|
735
|
+
# helpers
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _merge(fragments: list[dict]) -> tuple[list[dict], list[dict]]:
|
|
740
|
+
nodes: list[dict] = []
|
|
741
|
+
edges: list[dict] = []
|
|
742
|
+
seen_nodes: set[str] = set()
|
|
743
|
+
seen_edges: set[tuple] = set()
|
|
744
|
+
for fragment in fragments:
|
|
745
|
+
for node in fragment.get("nodes", []):
|
|
746
|
+
if node["id"] in seen_nodes:
|
|
747
|
+
continue
|
|
748
|
+
seen_nodes.add(node["id"])
|
|
749
|
+
nodes.append(node)
|
|
750
|
+
for edge in fragment.get("edges", []):
|
|
751
|
+
key = (edge.get("id"), edge.get("source"), edge.get("target"), edge.get("kind"))
|
|
752
|
+
if key in seen_edges:
|
|
753
|
+
continue
|
|
754
|
+
seen_edges.add(key)
|
|
755
|
+
edges.append(edge)
|
|
756
|
+
return nodes, edges
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _prefer_resolved_imports(edges: list[dict], *, suffix_map=None) -> list[dict]:
|
|
760
|
+
"""Merge resolved file evidence by endpoints, retaining every expression."""
|
|
761
|
+
from pathlib import PurePosixPath
|
|
762
|
+
|
|
763
|
+
suffix_map = suffix_languages() if suffix_map is None else suffix_map
|
|
764
|
+
|
|
765
|
+
def specifier(edge):
|
|
766
|
+
return import_token(edge.get("expression") or "", PurePosixPath(edge.get("path") or "").suffix, suffix_map)
|
|
767
|
+
|
|
768
|
+
resolved_declarations = {(e.get("source"), specifier(e)) for e in edges
|
|
769
|
+
if e.get("kind") == "import" and e.get("status") == "resolved"
|
|
770
|
+
and e.get("backend") != "joern" and specifier(e)}
|
|
771
|
+
groups = {}
|
|
772
|
+
result = []
|
|
773
|
+
# Prefer the dedicated resolver as the representative, independent of
|
|
774
|
+
# fragment ordering. Joern evidence is retained in expressions.
|
|
775
|
+
ordered = sorted(edges, key=lambda e: e.get("backend") == "joern")
|
|
776
|
+
for edge in ordered:
|
|
777
|
+
if (edge.get("kind") == "import" and edge.get("backend") == "joern"
|
|
778
|
+
and edge.get("status") == "unresolved" and specifier(edge)
|
|
779
|
+
and (edge.get("source"), specifier(edge)) in resolved_declarations):
|
|
780
|
+
continue
|
|
781
|
+
if edge.get("kind") != "import" or edge.get("status") != "resolved" or not edge.get("target"):
|
|
782
|
+
result.append(edge)
|
|
783
|
+
continue
|
|
784
|
+
key = (edge.get("source"), edge.get("target"), edge.get("kind"))
|
|
785
|
+
if key not in groups:
|
|
786
|
+
merged = dict(edge)
|
|
787
|
+
merged["expressions"] = list(edge.get("expressions") or ([edge["expression"]] if edge.get("expression") else []))
|
|
788
|
+
groups[key] = merged
|
|
789
|
+
result.append(merged)
|
|
790
|
+
else:
|
|
791
|
+
merged = groups[key]
|
|
792
|
+
if edge.get("resolution_basis") and not merged.get("resolution_basis"):
|
|
793
|
+
merged["resolution_basis"] = edge["resolution_basis"]
|
|
794
|
+
for expression in edge.get("expressions") or [edge.get("expression")]:
|
|
795
|
+
if expression and expression not in merged["expressions"]:
|
|
796
|
+
merged["expressions"].append(expression)
|
|
797
|
+
return result
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _unavailable(backend: str, languages: list[str], kinds: tuple[str, ...]) -> list[dict]:
|
|
801
|
+
return [{"language": language, "graph": kind, "backend": backend,
|
|
802
|
+
"status": "unavailable", "limitations": f"{backend} was not found"}
|
|
803
|
+
for language in languages for kind in kinds]
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _merge_capabilities(fragments: list[dict], requested: list[str],
|
|
807
|
+
extra: list[dict] | None = None) -> list[dict]:
|
|
808
|
+
merged: dict[tuple, dict] = {}
|
|
809
|
+
for capability in [item for fragment in fragments for item in fragment.get("capabilities", [])] + list(extra or []):
|
|
810
|
+
key = (capability.get("language"), capability.get("graph"))
|
|
811
|
+
current = merged.get(key)
|
|
812
|
+
if current is None or _CAPABILITY_RANK.get(capability.get("status"), 0) > _CAPABILITY_RANK.get(current.get("status"), 0):
|
|
813
|
+
merged[key] = capability
|
|
814
|
+
for language in requested:
|
|
815
|
+
for kind in ("import", "call", "cfg", "dataflow"):
|
|
816
|
+
merged.setdefault((language, kind), {
|
|
817
|
+
"language": language, "graph": kind, "status": "unsupported",
|
|
818
|
+
"backend": None, "limitations": "no backend reported this language/kind",
|
|
819
|
+
})
|
|
820
|
+
return sorted(merged.values(), key=lambda item: (item.get("language") or "", item.get("graph") or ""))
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _backend_record(components: list[dict]) -> dict:
|
|
824
|
+
if not components:
|
|
825
|
+
return {"name": "none", "version": None, "components": []}
|
|
826
|
+
if len(components) == 1:
|
|
827
|
+
return {"name": components[0]["name"], "version": components[0].get("version"),
|
|
828
|
+
"components": components}
|
|
829
|
+
names = "+".join(component["name"] for component in components)
|
|
830
|
+
return {"name": names, "version": None, "components": components}
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _fingerprint(manifest: list[dict], config_manifest: list[dict], components: list[dict]) -> str:
|
|
834
|
+
payload = json.dumps({"source": manifest, "config": config_manifest, "components": components},
|
|
835
|
+
sort_keys=True, separators=(",", ":"))
|
|
836
|
+
return "gp_" + hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32]
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _depcruise_version(binary, timeout: int) -> str:
|
|
840
|
+
import subprocess # noqa: PLC0415
|
|
841
|
+
|
|
842
|
+
try:
|
|
843
|
+
completed = subprocess.run([str(binary), "--version"], check=False, capture_output=True,
|
|
844
|
+
text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
845
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
846
|
+
return "unknown"
|
|
847
|
+
text = (completed.stdout or completed.stderr).strip()
|
|
848
|
+
return text.splitlines()[0] if text else "unknown"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _grimp_version() -> str:
|
|
852
|
+
try:
|
|
853
|
+
from importlib.metadata import version # noqa: PLC0415
|
|
854
|
+
|
|
855
|
+
return version("grimp")
|
|
856
|
+
except Exception:
|
|
857
|
+
return "unknown"
|