codebase-explorer 2.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. cbe/__init__.py +9 -0
  2. cbe/__main__.py +4 -0
  3. cbe/aggregation.py +183 -0
  4. cbe/api_completion.py +410 -0
  5. cbe/batching.py +283 -0
  6. cbe/catalog_query.py +376 -0
  7. cbe/checks.py +390 -0
  8. cbe/cli.py +434 -0
  9. cbe/concrete_facts.py +321 -0
  10. cbe/data/LICENSE.tiktoken +30 -0
  11. cbe/data/o200k_base.tiktoken +199998 -0
  12. cbe/declarations.py +537 -0
  13. cbe/docs.py +929 -0
  14. cbe/generate.py +316 -0
  15. cbe/grammars.py +741 -0
  16. cbe/graph.py +857 -0
  17. cbe/graph_backends/__init__.py +43 -0
  18. cbe/graph_backends/_calls.py +262 -0
  19. cbe/graph_backends/_paths.py +42 -0
  20. cbe/graph_backends/identity.py +93 -0
  21. cbe/graph_backends/imports.py +256 -0
  22. cbe/graph_backends/joern.py +1021 -0
  23. cbe/graph_backends/resolution.py +288 -0
  24. cbe/graph_backends/toolchain.py +396 -0
  25. cbe/grounding.py +57 -0
  26. cbe/host_run.py +1752 -0
  27. cbe/model_settings.py +80 -0
  28. cbe/projection.py +305 -0
  29. cbe/prompts.py +316 -0
  30. cbe/records.py +200 -0
  31. cbe/skeleton.py +474 -0
  32. cbe/source_context.py +709 -0
  33. cbe/symbols.py +136 -0
  34. cbe/token_budget.py +68 -0
  35. cbe/treename.py +398 -0
  36. cbe/usage.py +300 -0
  37. cbe_install/__init__.py +11 -0
  38. cbe_install/cli.py +434 -0
  39. cbe_install/resources/agents/cbe-writer.md +16 -0
  40. cbe_install/resources/codebase-explorer/SKILL.md +101 -0
  41. cbe_install/resources/codebase-explorer/references/claude-code.md +63 -0
  42. cbe_install/resources/codebase-explorer/references/codex.md +37 -0
  43. codebase_explorer-2.2.0.dist-info/METADATA +233 -0
  44. codebase_explorer-2.2.0.dist-info/RECORD +47 -0
  45. codebase_explorer-2.2.0.dist-info/WHEEL +4 -0
  46. codebase_explorer-2.2.0.dist-info/entry_points.txt +3 -0
  47. codebase_explorer-2.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1021 @@
1
+ """Joern backend for the ``cbe-graph/1`` contract.
2
+
3
+ This module wraps the upstream Joern CLI. It does not re-implement any
4
+ language analysis: it runs ``joern-parse`` once per frontend flag, then a
5
+ small CPGQL export script that emits targeted JSON (methods, calls, control
6
+ flow, native REACHING_DEF definition-use edges, import declarations, and
7
+ optionally requested value-flow paths).
8
+
9
+ Identity rules:
10
+
11
+ * Caller/callee/CFG identity comes from Joern's native node ids, not from
12
+ names. Two files that both define ``helper`` cannot be confused.
13
+ * A call with several callees keeps the alternatives and reports
14
+ ``ambiguous`` instead of inventing a single target.
15
+ * Data-flow identity is the native ``REACHING_DEF`` edge, not a
16
+ parameter→return path. Requested value-flow paths are a separate, clearly
17
+ scoped, sampled query and are never labelled as intra-procedural analysis.
18
+ * Analysis failures are recorded as diagnostics/errors. A thrown analyzer
19
+ error is never converted into an apparently successful empty graph.
20
+
21
+ Provenance kept per relation: backend name/version, the language frontend and
22
+ the raw expression.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import re
30
+ from collections import Counter
31
+ import shlex
32
+ import subprocess
33
+ from pathlib import Path
34
+
35
+ from .identity import repository_files, file_id
36
+ from ._calls import CallResolver
37
+ from .resolution import go_modules
38
+ from .toolchain import MIN_JAVA_MAJOR, JavaInstall, find_joern, java_environment, setup_hint # noqa: F401
39
+
40
+ #: language id (canonical, matches ``cbe.source_context``) -> Joern frontend
41
+ #: flag. ``pythonsrc``/``javasrc`` are the source frontends; ``python``/``java``
42
+ #: select the removed py2cpg and the bytecode jimple frontend and are wrong for
43
+ #: source trees.
44
+ JOERN_LANGUAGE_FLAGS: dict[str, str] = {
45
+ "c": "c",
46
+ "cpp": "c",
47
+ "python": "pythonsrc",
48
+ "javascript": "javascript",
49
+ "typescript": "javascript",
50
+ "tsx": "javascript",
51
+ "java": "javasrc",
52
+ "go": "golang",
53
+ "ruby": "rubysrc",
54
+ "rust": "rust",
55
+ "php": "php",
56
+ "c_sharp": "csharpsrc",
57
+ "kotlin": "kotlin",
58
+ "swift": "swiftsrc",
59
+ }
60
+
61
+ #: Joern frontends that are intentionally outside the source-context surface
62
+ #: because they consume bytecode or binaries, not source.
63
+ JOERN_BYTECODE_FRONTENDS = frozenset({"jimple", "ghidra", "llvm"})
64
+
65
+ #: frontend flag -> Joern frontend name, for provenance.
66
+ FRONTEND_NAME = {
67
+ "c": "c2cpg", "pythonsrc": "pysrc2cpg", "javascript": "jssrc2cpg",
68
+ "javasrc": "javasrc2cpg", "golang": "gosrc2cpg", "rubysrc": "rubysrc2cpg",
69
+ "rust": "rust2cpg", "php": "php2cpg", "csharpsrc": "csharpsrc2cpg",
70
+ "kotlin": "kotlin2cpg", "swiftsrc": "swiftsrc2cpg",
71
+ }
72
+
73
+ #: Syntax noise that is not a user-visible call relation.
74
+ _SYNTHETIC_CALL = re.compile(r"^(?:<operators?>|__ecma\.|_?tmp\d+\.)")
75
+
76
+ #: REACHING_DEF is capped so a very large program cannot exhaust the run dir.
77
+ DEFAULT_MAX_REACHING_DEFS = 50000
78
+
79
+
80
+ _EXPORT_SCRIPT = r"""
81
+ import io.shiftleft.codepropertygraph.generated.nodes.*
82
+ import io.joern.dataflowengineoss.language.*
83
+ import io.joern.dataflowengineoss.queryengine.EngineConfig
84
+ import java.nio.file.{Files, Paths}
85
+ import scala.util.{Try, Success, Failure}
86
+
87
+ @main def main(cpgFile: String, outFile: String, maxReachingDefs: Int,
88
+ pathMethods: String, maxPathsPerMethod: Int, maxMethods: Int, withDataflow: Int): Unit = {
89
+ val cpg = importCpg(cpgFile).get
90
+ implicit val engineConfig: EngineConfig = EngineConfig()
91
+
92
+ val realMethods: List[Method] = cpg.method.filterNot(m => m.isExternal || m.name == "<global>").l
93
+
94
+ // Owner method of every AST node, from the AST itself. This covers
95
+ // parameters, parameter-out and method-return nodes uniformly.
96
+ val ownerByNode: Map[Long, String] =
97
+ realMethods.flatMap(m => m.ast.map(n => n.id -> m.fullName)).toMap
98
+
99
+ def fileOf(n: AstNode): String =
100
+ try n.file.name.headOption.getOrElse("") catch { case _: Throwable => "" }
101
+ def ownerOf(n: AstNode): String = ownerByNode.getOrElse(n.id, "")
102
+ def elemJson(n: AstNode): ujson.Obj = ujson.Obj(
103
+ "label" -> n.label,
104
+ "line" -> n.lineNumber.getOrElse(-1),
105
+ "column" -> n.columnNumber.getOrElse(-1),
106
+ "code" -> n.code.linesIterator.toList.headOption.getOrElse(""),
107
+ "file" -> fileOf(n),
108
+ "ownerMethod" -> ownerOf(n),
109
+ "nodeId" -> n.id
110
+ )
111
+
112
+ val files = cpg.file.map(f => ujson.Obj("name" -> f.name, "hash" -> f.hash.getOrElse(""))).l
113
+
114
+ val types = cpg.typeDecl.filterNot(_.isExternal).map { t =>
115
+ ujson.Obj("name" -> t.name, "fullName" -> t.fullName,
116
+ "file" -> t.filename, "inherits" -> t.inheritsFromTypeFullName.toList)
117
+ }.l
118
+
119
+ val methods = realMethods.map { m =>
120
+ ujson.Obj(
121
+ "name" -> m.name,
122
+ "fullName" -> m.fullName,
123
+ "file" -> m.filename,
124
+ "line" -> m.lineNumber.getOrElse(-1),
125
+ "signature" -> m.signature,
126
+ "nodeId" -> m.id
127
+ )
128
+ }.l
129
+
130
+ var callsWithoutOwner = 0
131
+ val callsWithoutOwnerExamples = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
132
+ val calls = cpg.call.filter { c =>
133
+ val owner = c.method
134
+ if (owner == null) {
135
+ callsWithoutOwner += 1
136
+ if (callsWithoutOwnerExamples.size < 5) {
137
+ callsWithoutOwnerExamples += ujson.Obj(
138
+ "file" -> fileOf(c), "line" -> c.lineNumber.getOrElse(-1),
139
+ "code" -> c.code, "nodeId" -> c.id)
140
+ }
141
+ false
142
+ } else !owner.isExternal
143
+ }.map { c =>
144
+ val callees = c.callee.map(m => ujson.Obj(
145
+ "nodeId" -> m.id,
146
+ "fullName" -> m.fullName,
147
+ "external" -> m.isExternal,
148
+ "file" -> m.filename,
149
+ "line" -> m.lineNumber.getOrElse(-1)
150
+ )).l
151
+ ujson.Obj(
152
+ "caller" -> c.method.fullName,
153
+ "callerId" -> c.method.id,
154
+ "callerFile" -> c.method.filename,
155
+ "line" -> c.lineNumber.getOrElse(-1),
156
+ "column" -> c.columnNumber.getOrElse(-1),
157
+ "name" -> c.name,
158
+ "code" -> c.code,
159
+ "methodFullName" -> c.methodFullName,
160
+ "dispatchType" -> c.dispatchType.toString,
161
+ "callees" -> callees,
162
+ "nodeId" -> c.id
163
+ )
164
+ }.l
165
+
166
+ val cfgMethods = if (maxMethods > 0) realMethods.take(maxMethods) else realMethods
167
+ val cfg = cfgMethods.map { m =>
168
+ val nodes = m.cfgNode.l
169
+ val idx = nodes.zipWithIndex.map { case (n, i) => n.id -> i }.toMap
170
+ val nodeJson = nodes.zipWithIndex.map { case (n, i) =>
171
+ ujson.Obj(
172
+ "i" -> i,
173
+ "label" -> n.label,
174
+ "line" -> n.lineNumber.getOrElse(-1),
175
+ "column" -> n.columnNumber.getOrElse(-1),
176
+ "order" -> n.order,
177
+ "code" -> n.code.linesIterator.toList.headOption.getOrElse(""),
178
+ "file" -> fileOf(n),
179
+ "ownerMethod" -> ownerOf(n),
180
+ "nodeId" -> n.id
181
+ )
182
+ }
183
+ val edges = nodes.flatMap { n =>
184
+ n.out("CFG").map { o =>
185
+ val oc = o.asInstanceOf[CfgNode]
186
+ ujson.Obj("from" -> idx.getOrElse(n.id, -1), "to" -> idx.getOrElse(oc.id, -1))
187
+ }
188
+ }
189
+ ujson.Obj("method" -> m.fullName, "methodId" -> m.id, "file" -> m.filename,
190
+ "line" -> m.lineNumber.getOrElse(-1), "nodeId" -> m.id,
191
+ "nodes" -> nodeJson, "edges" -> edges)
192
+ }.l
193
+
194
+ val imports = cpg.imports.map { i =>
195
+ val f = i.file.name.headOption.getOrElse("")
196
+ ujson.Obj("file" -> f, "line" -> i.lineNumber.getOrElse(-1),
197
+ "code" -> i.code.linesIterator.toList.headOption.getOrElse(""), "nodeId" -> i.id)
198
+ }.l
199
+
200
+ // Native REACHING_DEF definition-use edges: the bounded base data-flow graph.
201
+ val rdSeen = scala.collection.mutable.Set[(Long, Long)]()
202
+ val rdBuf = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
203
+ var rdOmitted = 0
204
+ (if (withDataflow != 0) realMethods else List[Method]()).foreach { m =>
205
+ m.ast.foreach { n =>
206
+ if (n.label != "METHOD") {
207
+ n.out("REACHING_DEF").foreach { o =>
208
+ val t = o.asInstanceOf[AstNode]
209
+ val key = (n.id, t.id)
210
+ if (!rdSeen.contains(key)) {
211
+ rdSeen.add(key)
212
+ if (rdBuf.size >= maxReachingDefs) rdOmitted += 1
213
+ else rdBuf += ujson.Obj("source" -> elemJson(n), "target" -> elemJson(t))
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ // Optional, explicitly requested value-flow paths. Separate scope; sampled;
221
+ // the full path set is counted so the sample is visible.
222
+ val requested = if (withDataflow != 0) pathMethods.split(",").map(_.trim).filter(_.nonEmpty).toList else List[String]()
223
+ val pathBuf = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
224
+ val errBuf = scala.collection.mutable.ArrayBuffer[ujson.Obj]()
225
+ requested.foreach { name =>
226
+ Try {
227
+ cpg.method.filterNot(_.isExternal).name(name).foreach { m =>
228
+ val paths = m.ast.isReturn.reachableByFlows(m.parameter).l
229
+ paths.take(maxPathsPerMethod).foreach { p =>
230
+ pathBuf += ujson.Obj(
231
+ "method" -> m.fullName, "methodId" -> m.id,
232
+ "totalPaths" -> paths.size, "sampled" -> (paths.size > maxPathsPerMethod),
233
+ "scope" -> "value_flow_path",
234
+ "elements" -> p.elements.toList.map(elemJson)
235
+ )
236
+ }
237
+ }
238
+ } match {
239
+ case Failure(e) => errBuf += ujson.Obj(
240
+ "method" -> name,
241
+ "kind" -> e.getClass.getSimpleName,
242
+ "message" -> Option(e.getMessage).getOrElse(""))
243
+ case Success(_) => ()
244
+ }
245
+ }
246
+
247
+ val root = ujson.Obj(
248
+ "backend" -> "joern",
249
+ "cpgFile" -> cpgFile,
250
+ "files" -> files,
251
+ "types" -> types,
252
+ "methods" -> methods,
253
+ "calls" -> calls,
254
+ "callsWithoutOwner" -> callsWithoutOwner,
255
+ "callsWithoutOwnerExamples" -> callsWithoutOwnerExamples.toList,
256
+ "cfg" -> cfg,
257
+ "cfgMethodsOmitted" -> (realMethods.size - cfgMethods.size),
258
+ "imports" -> imports,
259
+ "dataflowRequested" -> (withDataflow != 0),
260
+ "reachingDefs" -> rdBuf.toList,
261
+ "reachingDefsTruncated" -> (rdOmitted > 0),
262
+ "reachingDefsOmitted" -> rdOmitted,
263
+ "dataflowPaths" -> pathBuf.toList,
264
+ "dataflowErrors" -> errBuf.toList
265
+ )
266
+ val writer = Files.newBufferedWriter(Paths.get(outFile), java.nio.charset.StandardCharsets.UTF_8)
267
+ try ujson.writeTo(root, writer, indent = -1) finally writer.close()
268
+ println("WROTE " + outFile)
269
+ }
270
+ """
271
+
272
+
273
+ class JoernError(RuntimeError):
274
+ """Raised when the Joern CLI is missing or a whole run fails."""
275
+
276
+
277
+ def _run(argv: list[str], timeout: int, cwd: Path, *, env=None) -> subprocess.CompletedProcess:
278
+ return subprocess.run(argv, cwd=str(cwd), check=False, env=env,
279
+ capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
280
+
281
+
282
+ def joern_version(joern_bin: Path, timeout: int, java: JavaInstall | None = None) -> str:
283
+ try:
284
+ completed = _run([str(joern_bin), "--version"], timeout=timeout, cwd=joern_bin.parent,
285
+ env=java_environment(java))
286
+ except (OSError, subprocess.TimeoutExpired):
287
+ return "unknown"
288
+ for line in (completed.stdout + completed.stderr).splitlines():
289
+ if line.strip().lower().startswith("version:"):
290
+ return line.split(":", 1)[1].strip()
291
+ return "unknown"
292
+
293
+
294
+ def build_with_joern(
295
+ repo: Path,
296
+ run_dir: Path,
297
+ languages: list[str],
298
+ joern_home: Path,
299
+ timeout: int,
300
+ *,
301
+ max_reaching_defs: int = DEFAULT_MAX_REACHING_DEFS,
302
+ dataflow_path_methods: list[str] | None = None,
303
+ max_paths_per_method: int = 5,
304
+ max_methods: int | None = None,
305
+ joern_heap: str | None = None,
306
+ java: JavaInstall | None = None,
307
+ with_dataflow: bool = False,
308
+ ) -> dict:
309
+ """Parse each frontend's languages with Joern and return a normalized fragment.
310
+
311
+ Returns ``{"backend", "nodes", "edges", "capabilities", "diagnostics",
312
+ "raw"}``. Raises :class:`JoernError` only when the CLI itself cannot run a
313
+ frontend; per-language parse/export failures become ``failed`` capabilities
314
+ plus diagnostics so a partial graph stays honest.
315
+ """
316
+
317
+ joern_bin = joern_home / "joern"
318
+ parse_bin = joern_home / "joern-parse"
319
+ run_dir.mkdir(parents=True, exist_ok=True)
320
+ script = run_dir / "joern_export.sc"
321
+ script.write_text(_EXPORT_SCRIPT, encoding="utf-8")
322
+
323
+ joern_env = java_environment(java)
324
+ if joern_heap:
325
+ joern_env = dict(joern_env or os.environ)
326
+ # Last -Xmx wins, preserving unrelated caller JVM options.
327
+ joern_env["JAVA_TOOL_OPTIONS"] = (joern_env.get("JAVA_TOOL_OPTIONS", "") + f" -Xmx{joern_heap}").strip()
328
+ version = joern_version(joern_bin, timeout, java)
329
+ roots = [str(repo), str(joern_home)]
330
+ diagnostics: list[dict] = []
331
+ capabilities: list[dict] = []
332
+ nodes: list[dict] = []
333
+ edges: list[dict] = []
334
+ raw: list[dict] = []
335
+ non_call_sites = Counter()
336
+ path_methods = ",".join(dataflow_path_methods or []) if with_dataflow else ""
337
+ files = repository_files(repo)
338
+
339
+ by_language: dict[str, list[str]] = {}
340
+ for language in languages:
341
+ flag = JOERN_LANGUAGE_FLAGS.get(language)
342
+ if flag is None:
343
+ diagnostics.append({
344
+ "path": None, "code": "language_unsupported",
345
+ "message": f"joern backend has no source frontend mapping for language {language!r}",
346
+ })
347
+ capabilities.append(_capability(language, "call", "unsupported", version,
348
+ backend="joern", limitations="no source frontend mapping"))
349
+ continue
350
+ by_language.setdefault(flag, []).append(language)
351
+
352
+ for flag, langs in by_language.items():
353
+ namespace = f"cpg:{flag}"
354
+ cpg = run_dir / f"cpg-{flag}.bin"
355
+ export = run_dir / f"cpg-{flag}.json"
356
+ try:
357
+ parse_argv = [str(parse_bin), str(repo), "--language", flag, "-o", str(cpg)]
358
+ if flag == "rubysrc":
359
+ parse_argv += ["--frontend-args", "--no-default-exclude"]
360
+ parsed = _run(parse_argv, timeout=timeout, cwd=run_dir, env=joern_env)
361
+ except subprocess.TimeoutExpired:
362
+ for language in langs:
363
+ capabilities.extend(_failed_capabilities(language, "joern", "backend_timeout"))
364
+ diagnostics.append({"path": None, "code": "backend_timeout",
365
+ "message": f"joern-parse timed out after {timeout}s for frontend {flag}"})
366
+ continue
367
+ except OSError as exc:
368
+ raise JoernError(f"cannot run joern-parse: {exc}") from exc
369
+ parse_output = (parsed.stdout or "") + "\n" + (parsed.stderr or "")
370
+ if parsed.returncode != 0 or not cpg.exists():
371
+ for language in langs:
372
+ capabilities.extend(_failed_capabilities(language, "joern", "backend_error"))
373
+ message = f"joern-parse failed for frontend {flag} (exit {parsed.returncode})"
374
+ cause = _first_error_line(parse_output, roots)
375
+ if cause:
376
+ message += f": {cause}"
377
+ if java is None:
378
+ message += f"; no Java {MIN_JAVA_MAJOR}+ was found, {setup_hint()}"
379
+ diagnostics.append({"path": None, "code": "backend_error", "message": message})
380
+ continue
381
+ try:
382
+ exported = _run(
383
+ [str(joern_bin), "--script", str(script),
384
+ "--param", f"cpgFile={cpg}", "--param", f"outFile={export}",
385
+ "--param", f"maxMethods={max_methods if max_methods is not None else -1}",
386
+ "--param", f"withDataflow={int(with_dataflow)}",
387
+ "--param", f"maxReachingDefs={max_reaching_defs}",
388
+ "--param", f"pathMethods={path_methods}",
389
+ "--param", f"maxPathsPerMethod={max_paths_per_method}", "--nocolors"],
390
+ timeout=timeout, cwd=run_dir, env=joern_env)
391
+ except subprocess.TimeoutExpired:
392
+ for language in langs:
393
+ capabilities.extend(_failed_capabilities(language, "joern", "backend_timeout"))
394
+ diagnostics.append({"path": None, "code": "backend_timeout",
395
+ "message": f"joern export timed out after {timeout}s for frontend {flag}"})
396
+ continue
397
+ if exported.returncode != 0 or not export.exists():
398
+ for language in langs:
399
+ capabilities.extend(_failed_capabilities(language, "joern", "backend_error"))
400
+ message = f"joern export failed for frontend {flag} (exit {exported.returncode})"
401
+ cause = _first_error_line((exported.stderr or "") + "\n" + (exported.stdout or ""), roots)
402
+ if cause:
403
+ message += f": {cause}"
404
+ diagnostics.append({"path": None, "code": "backend_error", "message": message})
405
+ continue
406
+ try:
407
+ payload = json.loads(export.read_text(encoding="utf-8"))
408
+ except (OSError, json.JSONDecodeError) as exc:
409
+ for language in langs:
410
+ capabilities.extend(_failed_capabilities(language, "joern", "backend_error"))
411
+ diagnostics.append({"path": None, "code": "backend_error",
412
+ "message": f"joern export unreadable for frontend {flag}: {exc}"})
413
+ continue
414
+ if flag == "csharpsrc":
415
+ _one_based_lines(payload)
416
+ payload["_languages"] = langs
417
+ payload["_frontend"] = FRONTEND_NAME.get(flag, flag)
418
+ payload["_repo_root"] = str(repo)
419
+ payload["_go_modules"] = go_modules(repo, files)
420
+ payload["_javascript_packages"] = _javascript_packages(repo, files)
421
+ payload["_c_macros"] = sorted(_c_macro_names(repo, files)) if flag == "c" else []
422
+ _attach_call_source_lines(payload, repo)
423
+ raw.append(payload)
424
+ payload["_files"] = files
425
+ f_nodes, f_edges = _normalize(payload, version, namespace, diagnostics=diagnostics)
426
+ nodes.extend(f_nodes)
427
+ edges.extend(f_edges)
428
+ non_call_sites.update(payload.get("_non_call_sites", {}))
429
+ frontend_error = None
430
+ if not payload.get("methods"):
431
+ # joern-parse may have exited 0 after a frontend failed to load the
432
+ # project; recover the real cause instead of reporting empty success.
433
+ frontend_error = _frontend_error_from_direct_run(parse_output, run_dir, timeout, roots, env=joern_env)
434
+ if frontend_error:
435
+ diagnostics.append({"path": None, "code": "frontend_error",
436
+ "message": f"joern frontend {flag} reported an error while "
437
+ f"producing no methods: {frontend_error}"})
438
+ for language in langs:
439
+ capabilities.extend(_capabilities_for(language, payload, version,
440
+ frontend_error=frontend_error))
441
+ for error in payload.get("dataflowErrors", []):
442
+ diagnostics.append({"path": None, "code": "dataflow_path_error",
443
+ "message": f"value-flow path query failed for {error.get('method')}: "
444
+ f"{error.get('kind')}: {error.get('message')}"})
445
+ omitted = payload.get("cfgMethodsOmitted", 0)
446
+ if omitted:
447
+ diagnostics.append({"path": None, "code": "cfg_methods_omitted",
448
+ "message": f"CFG export for frontend {flag} omitted {omitted} methods "
449
+ f"(max_methods={max_methods})"})
450
+ if payload.get("reachingDefsTruncated"):
451
+ diagnostics.append({"path": None, "code": "reaching_defs_truncated",
452
+ "edges_omitted": payload.get("reachingDefsOmitted"),
453
+ "message": f"REACHING_DEF export for frontend {flag} hit the "
454
+ f"{max_reaching_defs}-edge cap; "
455
+ f"{payload.get('reachingDefsOmitted', 'unknown')} edges omitted"})
456
+
457
+ # A whole-run CLI failure (OSError) raises above. Per-frontend failures are
458
+ # returned as ``failed`` capabilities + diagnostics so the exact cause
459
+ # reaches the caller instead of a generic "no backend" error.
460
+ if not raw and not (capabilities or diagnostics):
461
+ diagnostics.append({"path": None, "code": "no_language_requested",
462
+ "message": "no Joern source language was requested"})
463
+ return {
464
+ "backend": {"name": "joern", "version": version},
465
+ "nodes": nodes,
466
+ "edges": edges,
467
+ "capabilities": capabilities,
468
+ "diagnostics": diagnostics,
469
+ "non_call_sites": dict(sorted(non_call_sites.items())),
470
+ "raw": raw,
471
+ }
472
+
473
+
474
+ def _c_macro_names(repo: Path, files) -> set[str]:
475
+ """Read real C/C++ #define nodes with the grammars already used by CBE."""
476
+ from cbe.grammars import _grammar_for_suffix, _make_parser # noqa: PLC0415
477
+ names = set()
478
+ parsers = {}
479
+ for path in sorted(files):
480
+ suffix = Path(path).suffix.lower()
481
+ if suffix not in {".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"}:
482
+ continue
483
+ spec = _grammar_for_suffix(suffix)
484
+ if spec is None:
485
+ continue
486
+ try:
487
+ parser = parsers.get(spec.name)
488
+ if parser is None:
489
+ parser = parsers[spec.name] = _make_parser(spec)
490
+ data = (repo / path).read_bytes()
491
+ root = parser.parse(data).root_node
492
+ stack = [root]
493
+ while stack:
494
+ node = stack.pop()
495
+ if node.type in {"preproc_def", "preproc_function_def"}:
496
+ name = node.child_by_field_name("name")
497
+ if name is not None:
498
+ names.add(data[name.start_byte:name.end_byte].decode("utf-8"))
499
+ stack.extend(node.children)
500
+ except (OSError, ValueError, RuntimeError, ImportError):
501
+ continue
502
+ return names
503
+
504
+
505
+ def _attach_call_source_lines(payload: dict, repo: Path) -> None:
506
+ """Add source evidence only where generated calls need disambiguation."""
507
+ cache = {}
508
+ for call in payload.get("calls", []):
509
+ if call.get("name") != "require" or not str(call.get("callerFile", "")).endswith(
510
+ (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")):
511
+ continue
512
+ path = _norm_path(call.get("callerFile", ""))
513
+ line = call.get("line")
514
+ if not path or not isinstance(line, int) or line <= 0:
515
+ continue
516
+ if path not in cache:
517
+ try:
518
+ cache[path] = (repo / path).read_text(encoding="utf-8").splitlines()
519
+ except OSError:
520
+ cache[path] = []
521
+ if line <= len(cache[path]):
522
+ statement = []
523
+ for source_line in cache[path][line - 1:line + 19]:
524
+ statement.append(source_line.strip())
525
+ if ";" in source_line:
526
+ break
527
+ call["sourceCode"] = "\n".join(statement)
528
+
529
+
530
+ def _javascript_packages(repo: Path, files) -> list[str]:
531
+ names = set()
532
+ for path in sorted(files):
533
+ if Path(path).name != "package.json":
534
+ continue
535
+ try:
536
+ value = json.loads((repo / path).read_text(encoding="utf-8"))
537
+ except (OSError, json.JSONDecodeError):
538
+ continue
539
+ if isinstance(value, dict) and isinstance(value.get("name"), str):
540
+ names.add(value["name"])
541
+ return sorted(names)
542
+
543
+
544
+ def _norm_path(raw: str) -> str | None:
545
+ """Normalize a Joern filename to a repo-relative POSIX path, or ``None``."""
546
+
547
+ from ._paths import canonical_repo_path # noqa: PLC0415
548
+
549
+ return canonical_repo_path(raw)
550
+
551
+
552
+ _ERROR_MARKERS = re.compile(
553
+ r"(?i)\b(?:error|exception|fail(?:ed|ure)?|not found|no such file|cannot run|unable to)\b")
554
+ _PRIMARY_CAUSE = re.compile(
555
+ r"(?i)(failed to|cannot run|no such file|not found|unable to|are .{0,40}on your path|missing)")
556
+
557
+
558
+ def _scrub_text(text: str, roots: list[str]) -> str:
559
+ scrubbed = text
560
+ for root in roots:
561
+ if root:
562
+ scrubbed = scrubbed.replace(str(root), "<path>")
563
+ return scrubbed
564
+
565
+
566
+ def _first_error_line(text: str, roots: list[str]) -> str | None:
567
+ candidates = [line.strip() for line in (text or "").splitlines()
568
+ if (_ERROR_MARKERS.search(line) or "OutOfMemoryError" in line)
569
+ and "process exited with code" not in line.lower()]
570
+ if not candidates:
571
+ return None
572
+ primary = [line for line in candidates if _PRIMARY_CAUSE.search(line)]
573
+ oom = [line for line in candidates if "OutOfMemoryError" in line]
574
+ chosen = max(oom or primary or candidates, key=len)
575
+ return _scrub_text(chosen, roots)[:300]
576
+
577
+
578
+ def _frontend_error_from_direct_run(parse_output: str, run_dir: Path, timeout: int,
579
+ roots: list[str], *, env=None) -> str | None:
580
+ """Re-run the exact frontend command ``joern-parse`` used, to capture its error.
581
+
582
+ ``joern-parse`` can exit 0 while a frontend logged a load failure and wrote
583
+ an empty CPG. Only called when an export produced no methods, so a working
584
+ language pays nothing for it.
585
+ """
586
+
587
+ match = re.search(r"invoke the frontend:\s*(.+)", parse_output or "")
588
+ if not match:
589
+ return None
590
+ try:
591
+ argv = shlex.split(match.group(1))
592
+ except ValueError:
593
+ return None
594
+ if not argv:
595
+ return None
596
+ try:
597
+ completed = _run(argv, timeout, run_dir, env=env)
598
+ except (OSError, subprocess.TimeoutExpired):
599
+ return None
600
+ return _first_error_line((completed.stdout or "") + "\n" + (completed.stderr or ""), roots)
601
+
602
+
603
+ def _non_call_reason(call: dict, imports=(), types=(), macro_names=()) -> str | None:
604
+ """Classify analyzer-generated call nodes using language-specific facts."""
605
+ name = str(call.get("name", ""))
606
+ code = str(call.get("code", ""))
607
+ full = str(call.get("methodFullName", ""))
608
+ path = str(call.get("callerFile", ""))
609
+ if name.startswith(("<operator>", "<operators>")):
610
+ return "operator" # Joern's node for +, =, [] and the like: never a call site, not counted
611
+ if _SYNTHETIC_CALL.match(name) or full.startswith("ANY.ANY:"):
612
+ return "synthetic"
613
+ if path.endswith((".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh")) \
614
+ and (call.get("dispatchType") == "INLINED" or name in macro_names):
615
+ return "macro"
616
+ cpp_builtin = {"bool", "char", "short", "int", "long", "float", "double",
617
+ "signed", "unsigned", "wchar_t", "char8_t", "char16_t", "char32_t"}
618
+ if path.endswith((".cc", ".cpp", ".cxx", ".h", ".hpp", ".hh")):
619
+ if name in cpp_builtin and full.startswith(name + "." + name + ":"):
620
+ return "type_conversion"
621
+ if full.startswith("struct.struct:") and "{" in code and "}" in code:
622
+ return "literal"
623
+ if path.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts")):
624
+ source = str(call.get("sourceCode", ""))
625
+ if name == "require" and (any(imp.get("file") == path and imp.get("line") == call.get("line")
626
+ for imp in imports)
627
+ or re.match(r"^(?:import|export)\b[\s\S]*\bfrom\b", source)):
628
+ return "import_statement"
629
+ if (re.fullmatch(r"_iterator_\d+\.next\(\)", code)
630
+ or re.fullmatch(r"_tmp_\d+\.push\(.*\)", code)):
631
+ return "synthetic"
632
+ if path.endswith(".py"):
633
+ if ("<fakeNew>" in code or "__newInstance.__init__" in code
634
+ or code.startswith("def <body>") or re.fullmatch(r"enter_tmp\d+\(\)", code)
635
+ or code == "__exit__()"):
636
+ return "synthetic"
637
+ if full == "<unknownFullName>" and name in {"__iter__", "__next__"}:
638
+ return "synthetic"
639
+ callees = call.get("callees") or []
640
+ if ((str(call.get("caller", "")).endswith(":<module>")
641
+ or "<metaClassAdapter>" in str(call.get("caller", ""))) and len(callees) == 1
642
+ and not callees[0].get("external")
643
+ and callees[0].get("line") == call.get("line")
644
+ and re.fullmatch(r"[A-Za-z_]\w*\([^)]*\)", code)):
645
+ return "synthetic"
646
+ go_builtin_conversion = (not name and full.endswith(".") and re.match(
647
+ r"^(?:\[\])?(?:string|bool|byte|rune|error|int(?:8|16|32|64)?|uint(?:8|16|32|64)?|uintptr|"
648
+ r"float(?:32|64)|complex(?:64|128))\(", code))
649
+ if go_builtin_conversion:
650
+ return "type_conversion"
651
+ go_literal_type = any(str(value.get("file", "")).endswith(".go")
652
+ and full == str(value.get("fullName", "")) + ".<init>" for value in types)
653
+ if (path.endswith(".go") or go_literal_type) and full.endswith(".<init>") \
654
+ and "{" in code and "}" in code:
655
+ return "literal"
656
+ if path.endswith(".rb"):
657
+ if full == "<unknownFullName>" and code.startswith("defined?("):
658
+ return "synthetic"
659
+ if full == "__core.Array.initialize" and (code.startswith("%w[") or
660
+ (code.startswith("[") and code.endswith("]"))):
661
+ return "literal"
662
+ if path.endswith(".go"):
663
+ go_types = {"string", "bool", "byte", "rune", "error", "int", "int8", "int16",
664
+ "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64",
665
+ "uintptr", "float32", "float64", "complex64", "complex128"}
666
+ if (name in go_types or (not name and full.endswith("."))
667
+ or any(name == value.get("name") and full == value.get("fullName") for value in types)):
668
+ return "type_conversion"
669
+ if name == "<body>":
670
+ return "synthetic"
671
+ return None
672
+
673
+
674
+ def _visible_call(call: dict, imports=(), types=(), macro_names=()) -> bool:
675
+ return _non_call_reason(call, imports, types, macro_names) is None
676
+
677
+
678
+ def _absent_files(language: str, payload: dict) -> list[str]:
679
+ from cbe.grammars import suffix_languages # noqa: PLC0415
680
+ suffixes = {suffix for suffix, lang in suffix_languages().items() if lang == language}
681
+ present = {_norm_path(method.get("file", "")) for method in payload.get("methods", [])}
682
+ return sorted(path for path in payload.get("_files") or ()
683
+ if Path(path).suffix.lower() in suffixes and path not in present)
684
+
685
+
686
+ def _capability(language, graph, status, version, *, backend, limitations=None,
687
+ condition=None, **extra) -> dict:
688
+ entry = {"language": language, "graph": graph, "backend": backend,
689
+ "version": version, "status": status}
690
+ if limitations:
691
+ entry["limitations"] = limitations
692
+ if condition:
693
+ entry["condition"] = condition
694
+ entry.update(extra)
695
+ return entry
696
+
697
+
698
+ def _failed_capabilities(language: str, backend: str, code: str) -> list[dict]:
699
+ return [_capability(language, kind, "failed", None, backend=backend, limitations=code)
700
+ for kind in ("call", "cfg", "dataflow", "import")]
701
+
702
+
703
+ def _one_based_lines(value) -> None:
704
+ """Joern 4.0's C# frontend numbers lines from 0; every other frontend and CBE from 1.
705
+
706
+ Adds 1 to every known ``line`` of the export in place (a missing line stays -1)."""
707
+ if isinstance(value, dict):
708
+ line = value.get("line")
709
+ if isinstance(line, int) and not isinstance(line, bool) and line >= 0:
710
+ value["line"] = line + 1
711
+ for item in value.values():
712
+ if isinstance(item, (dict, list)):
713
+ _one_based_lines(item)
714
+ elif isinstance(value, list):
715
+ for item in value:
716
+ _one_based_lines(item)
717
+
718
+
719
+ def _normalize(payload: dict, version: str, namespace: str = "cpg", *,
720
+ diagnostics: list[dict] | None = None) -> tuple[list[dict], list[dict]]:
721
+ """Map a raw Joern export payload to standard nodes/edges.
722
+
723
+ Every id is namespaced by the analyzed CPG and keyed on Joern's native
724
+ node id, so same-name methods, overloads and independently numbered
725
+ language CPGs cannot collide.
726
+ """
727
+
728
+ nodes: list[dict] = []
729
+ edges: list[dict] = []
730
+ seen_nodes: set[str] = set()
731
+ source_files = payload.get("_files")
732
+ if source_files is not None:
733
+ from cbe.grammars import suffix_languages
734
+ suffixes = suffix_languages()
735
+ source_files = {p for p in source_files if Path(p).suffix.lower() in suffixes}
736
+
737
+ def add_node(node_id: str, kind: str, *, path=None, start=None, end=None, name=None,
738
+ external=False, owner=None, joern_id=None, label=None, backend="joern") -> None:
739
+ if node_id in seen_nodes:
740
+ return
741
+ seen_nodes.add(node_id)
742
+ if path is not None and source_files is not None and path not in source_files:
743
+ if diagnostics is not None:
744
+ diagnostics.append({"code": "unknown_node_location", "path": None,
745
+ "node": node_id, "analyzer_path": path,
746
+ "message": f"Joern node {node_id} has no source file for {path!r}; location left unknown"})
747
+ path = None
748
+ entry = {"id": node_id, "kind": kind, "path": path, "start_line": start,
749
+ "end_line": end, "name": name, "backend": backend}
750
+ if external:
751
+ entry["external"] = True
752
+ if owner:
753
+ entry["owner"] = owner
754
+ if label:
755
+ entry["label"] = label
756
+ if joern_id is not None:
757
+ entry["analyzer_id"] = joern_id
758
+ nodes.append(entry)
759
+
760
+ method_by_native: dict[int, str] = {}
761
+ for method in payload.get("methods", []):
762
+ rel = _norm_path(method.get("file", ""))
763
+ native = method.get("nodeId")
764
+ node_id = f"{namespace}/method/{native}" if native is not None else \
765
+ f"{namespace}/method/{rel}:{method.get('line', -1)}:{method.get('fullName', '')}"
766
+ full = method.get("fullName") or method.get("name", "")
767
+ add_node(node_id, "method" if "." in full else "function", path=rel,
768
+ start=method.get("line"), end=method.get("line"), name=full,
769
+ joern_id=native)
770
+ if native is not None:
771
+ method_by_native[int(native)] = node_id
772
+
773
+ call_resolver = CallResolver(payload.get("methods", []), payload.get("_files"),
774
+ payload.get("types"), payload.get("_go_modules"),
775
+ payload.get("_javascript_packages"))
776
+ non_call_sites = Counter()
777
+ for call in payload.get("calls", []):
778
+ reason = _non_call_reason(call, payload.get("imports", []), payload.get("types", []),
779
+ payload.get("_c_macros", []))
780
+ if reason:
781
+ if reason != "operator":
782
+ non_call_sites[reason] += 1
783
+ continue
784
+ rel = _norm_path(call.get("callerFile", ""))
785
+ native_caller = call.get("callerId")
786
+ caller = method_by_native.get(int(native_caller)) if native_caller is not None else None
787
+ if caller is None:
788
+ caller = f"{namespace}/caller/{rel}:{call.get('line', -1)}:{call.get('caller', '')}"
789
+ add_node(caller, "function", path=rel, start=call.get("line"), name=call.get("caller"))
790
+ status, native_targets, external_names = call_resolver.resolve(call)
791
+ internal = [method_by_native[native] for native in native_targets]
792
+ externals = []
793
+ for full in external_names:
794
+ eid = f"{namespace}/ext/{full}"
795
+ add_node(eid, "external", name=full, external=True)
796
+ externals.append(eid)
797
+ candidates = internal + externals
798
+ target = candidates[0] if status in {"resolved", "external"} and candidates else None
799
+ edge = {
800
+ "id": f"{namespace}/call/{call.get('nodeId')}",
801
+ "source": caller, "target": target, "kind": "call", "status": status,
802
+ "path": rel, "line": call.get("line"), "column": call.get("column"),
803
+ "expression": call.get("code"),
804
+ "method_full_name": external_names[0] if len(external_names) == 1 else call.get("methodFullName"),
805
+ "analyzer_method_full_name": call.get("methodFullName"),
806
+ "analyzer_id": call.get("nodeId"), "backend": "joern",
807
+ }
808
+ if len(candidates) > 1:
809
+ edge["candidates"] = candidates
810
+ edges.append(edge)
811
+
812
+ for cfg in payload.get("cfg", []):
813
+ method_native = cfg.get("methodId", cfg.get("nodeId"))
814
+ owner_node = method_by_native.get(int(method_native)) if method_native is not None else None
815
+ rel = _norm_path(cfg.get("file", ""))
816
+ index_to_node: dict[int, str] = {}
817
+ for node in cfg.get("nodes", []):
818
+ i = node.get("i")
819
+ node_id = f"{namespace}/cfg/{method_native}/{i}"
820
+ index_to_node[i] = node_id
821
+ add_node(node_id, "cfg_node", path=_norm_path(node.get("file", "")) or rel,
822
+ start=node.get("line"), end=node.get("line"), name=node.get("label"),
823
+ owner=owner_node, label=node.get("label"), joern_id=node.get("nodeId"))
824
+ for edge in cfg.get("edges", []):
825
+ source = index_to_node.get(edge.get("from"))
826
+ target = index_to_node.get(edge.get("to"))
827
+ if source is None or target is None:
828
+ continue
829
+ edges.append({
830
+ "id": f"{namespace}/cfgedge/{method_native}/{edge.get('from')}->{edge.get('to')}",
831
+ "source": source, "target": target, "kind": "cfg", "status": "resolved",
832
+ "path": rel, "owner": owner_node, "backend": "joern",
833
+ })
834
+
835
+ payload["_non_call_sites"] = dict(sorted(non_call_sites.items()))
836
+
837
+ import_declarations = [imp for imp in payload.get("imports", [])
838
+ if imp.get("file") and imp.get("code") not in (None, "", "<empty>")]
839
+ import_keys = {(imp.get("file"), imp.get("line"), imp.get("code")) for imp in import_declarations}
840
+ for call in payload.get("calls", []):
841
+ if (call.get("name") not in {"require", "require_relative"}
842
+ or not str(call.get("callerFile", "")).endswith(".rb")):
843
+ continue
844
+ key = (call.get("callerFile"), call.get("line"), call.get("code"))
845
+ if key not in import_keys:
846
+ import_keys.add(key)
847
+ import_declarations.append({"file": key[0], "line": key[1], "code": key[2],
848
+ "nodeId": f"{call.get('nodeId')}:import"})
849
+ for imp in import_declarations:
850
+ rel = _norm_path(imp.get("file", ""))
851
+ file_node_id = file_id(rel, payload.get("_files", {rel}))
852
+ if file_node_id:
853
+ add_node(file_node_id, "file", path=rel, name=Path(rel).name)
854
+ edges.append({
855
+ "id": f"{namespace}/import/{imp.get('nodeId')}",
856
+ "source": file_node_id, "target": None, "kind": "import", "status": "unresolved",
857
+ "path": rel, "line": imp.get("line"), "expression": imp.get("code"),
858
+ "analyzer_id": imp.get("nodeId"),
859
+ "backend": "joern",
860
+ })
861
+
862
+ def add_flow_node(element: dict) -> str:
863
+ eid = f"{namespace}/dfn/{element.get('nodeId')}"
864
+ add_node(eid, "dataflow_node", path=_norm_path(element.get("file", "")),
865
+ start=element.get("line"), end=element.get("line"),
866
+ name=element.get("label"), owner=element.get("ownerMethod"),
867
+ label=element.get("label"), joern_id=element.get("nodeId"))
868
+ return eid
869
+
870
+ for rd in payload.get("reachingDefs", []):
871
+ source_el = rd.get("source", {})
872
+ target_el = rd.get("target", {})
873
+ source = add_flow_node(source_el)
874
+ target = add_flow_node(target_el)
875
+ same_owner = (source_el.get("ownerMethod") and
876
+ source_el.get("ownerMethod") == target_el.get("ownerMethod"))
877
+ edges.append({
878
+ "id": f"{namespace}/rd/{source_el.get('nodeId')}->{target_el.get('nodeId')}",
879
+ "source": source, "target": target, "kind": "dataflow", "status": "resolved",
880
+ "path": _norm_path(source_el.get("file", "")),
881
+ "scope": "reaching_def_intra" if same_owner else "reaching_def_inter",
882
+ "backend": "joern",
883
+ })
884
+
885
+ for path in payload.get("dataflowPaths", []):
886
+ method_native = path.get("methodId")
887
+ previous = None
888
+ for position, element in enumerate(path.get("elements", [])):
889
+ eid = add_flow_node(element)
890
+ if previous is not None:
891
+ edges.append({
892
+ "id": f"{namespace}/vfp/{method_native}/{position}",
893
+ "source": previous, "target": eid, "kind": "dataflow", "status": "resolved",
894
+ "path": _norm_path(element.get("file", "")), "scope": "value_flow_path",
895
+ "sampled": bool(path.get("sampled")), "backend": "joern",
896
+ })
897
+ previous = eid
898
+
899
+ if diagnostics is not None:
900
+ diagnostics.extend(call_resolver.diagnostics)
901
+ calls_without_owner = payload.get("callsWithoutOwner", 0)
902
+ if calls_without_owner:
903
+ diagnostics.append({
904
+ "path": None, "code": "calls_without_owner_omitted",
905
+ "count": calls_without_owner,
906
+ "examples": payload.get("callsWithoutOwnerExamples", []),
907
+ "message": f"Joern export omitted {calls_without_owner} call nodes with no containing method",
908
+ })
909
+ if payload.get("methods"):
910
+ for language in payload.get("_languages", []):
911
+ absent = _absent_files(language, payload)
912
+ if absent:
913
+ diagnostics.append({"path": None, "code": "files_absent_from_graph",
914
+ "language": language, "count": len(absent),
915
+ "examples": absent[:5],
916
+ "message": f"{len(absent)} {language} manifest files have no method nodes: "
917
+ + ", ".join(absent[:5])})
918
+ return nodes, edges
919
+
920
+
921
+ def _capabilities_for(language: str, payload: dict, version: str,
922
+ *, frontend_error: str | None = None) -> list[dict]:
923
+ if frontend_error:
924
+ return [_capability(language, kind, "failed", version, backend="joern",
925
+ limitations=f"frontend error: {frontend_error}")
926
+ for kind in ("call", "cfg", "dataflow", "import")]
927
+ calls = [call for call in payload.get("calls", [])
928
+ if _visible_call(call, payload.get("imports", []), payload.get("types", []),
929
+ payload.get("_c_macros", []))]
930
+ non_call_sites = Counter(
931
+ reason for call in payload.get("calls", [])
932
+ if (reason := _non_call_reason(call, payload.get("imports", []), payload.get("types", []),
933
+ payload.get("_c_macros", []))) not in (None, "operator"))
934
+ resolver = CallResolver(payload.get("methods", []), payload.get("_files"),
935
+ payload.get("types"), payload.get("_go_modules"),
936
+ payload.get("_javascript_packages"))
937
+ statuses = [resolver.resolve(call)[0] for call in calls]
938
+ resolved = statuses.count("resolved")
939
+ external = statuses.count("external")
940
+ ambiguous = statuses.count("ambiguous")
941
+ unresolved = len(calls) - resolved - external - ambiguous
942
+
943
+ if resolved and not (ambiguous or unresolved):
944
+ call_status = "resolved"
945
+ elif calls:
946
+ call_status = "partial"
947
+ else:
948
+ call_status = "empty"
949
+
950
+ cfg_methods = sum(1 for cfg in payload.get("cfg", []) if cfg.get("edges"))
951
+ rd_edges = len(payload.get("reachingDefs", []))
952
+ has_methods = bool(payload.get("methods"))
953
+ if not payload.get("dataflowRequested", True):
954
+ df_status = "not_requested"
955
+ elif payload.get("reachingDefsTruncated"):
956
+ df_status = "partial"
957
+ elif rd_edges:
958
+ df_status = "resolved"
959
+ elif has_methods:
960
+ df_status = "empty"
961
+ else:
962
+ df_status = "unsupported"
963
+
964
+ conditions = []
965
+ if language == "cpp":
966
+ conditions.append("virtual/abstract calls bind to the static base declaration")
967
+ if language in ("c", "cpp", "java", "go", "c_sharp"):
968
+ conditions.append("coverage depends on the build/compiler configuration given to the frontend")
969
+ if language in ("javascript", "typescript", "tsx", "python", "ruby"):
970
+ conditions.append("dynamic dispatch and monkey-patching can hide call targets")
971
+ if language == "ruby":
972
+ conditions.append("rubysrc2cpg runs with --no-default-exclude; remaining absent files are diagnosed")
973
+ if language == "rust":
974
+ conditions.append("rust2cpg requires a Cargo project layout (Cargo.toml + src)")
975
+ if language == "php":
976
+ conditions.append("php2cpg requires a PHP >= 7 runtime on PATH")
977
+ if language == "swift":
978
+ conditions.append("swiftsrc2cpg parses files independently; cross-file calls may stay external")
979
+ if language == "c_sharp":
980
+ conditions.append("cross-file resolution needs a shared namespace or project")
981
+ call_notes = []
982
+ if external:
983
+ call_notes.append(f"{external} external")
984
+ if ambiguous:
985
+ call_notes.append(f"{ambiguous} ambiguous (multiple callees preserved)")
986
+ if unresolved:
987
+ call_notes.append(f"{unresolved} unresolved")
988
+ if not calls:
989
+ call_notes.append("analyzer ran but produced no user-visible calls")
990
+ if non_call_sites:
991
+ call_notes.append("excluded non-call sites: " + ", ".join(
992
+ f"{count} {reason}" for reason, count in sorted(non_call_sites.items())))
993
+ absent = _absent_files(language, payload)
994
+ if absent:
995
+ call_notes.append(f"{len(absent)} manifest files have no method nodes")
996
+
997
+ return [
998
+ _capability(language, "call", call_status, version, backend="joern",
999
+ limitations="; ".join(call_notes) or None,
1000
+ condition="; ".join(conditions) or None,
1001
+ resolved=resolved, external=external, ambiguous=ambiguous,
1002
+ unresolved=unresolved, non_call_sites=dict(sorted(non_call_sites.items()))),
1003
+ _capability(language, "cfg", "partial" if payload.get("cfgMethodsOmitted", 0) else ("resolved" if cfg_methods else ("empty" if has_methods else "unsupported")),
1004
+ version, backend="joern", methods_with_edges=cfg_methods,
1005
+ methods_omitted=payload.get("cfgMethodsOmitted", 0),
1006
+ limitations=f"{payload['cfgMethodsOmitted']} methods omitted" if payload.get("cfgMethodsOmitted") else None),
1007
+ _capability(language, "dataflow", df_status, version, backend="joern",
1008
+ scope="reaching_def", reaching_def_edges=rd_edges,
1009
+ reaching_defs_truncated=bool(payload.get("reachingDefsTruncated")),
1010
+ edges_omitted=payload.get("reachingDefsOmitted", None if payload.get("reachingDefsTruncated") else 0),
1011
+ limitations=("dataflow was not exported; rebuild with `cbe graph build --with-dataflow`"
1012
+ if df_status == "not_requested" else
1013
+ f"REACHING_DEF export truncated; {payload.get('reachingDefsOmitted', 'unknown')} edges omitted"
1014
+ if payload.get("reachingDefsTruncated") else
1015
+ (None if rd_edges else "analyzer ran but produced no REACHING_DEF edges")),
1016
+ condition="local reaching definitions; inter-procedural value-flow paths are a separate opt-in query"),
1017
+ _capability(language, "import",
1018
+ "partial" if payload.get("imports") else ("empty" if has_methods else "unsupported"),
1019
+ version, backend="joern",
1020
+ limitations="static import/include declarations; repository resolution applied at graph merge"),
1021
+ ]