codebase-explorer 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
cbe/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"