fix(cpg): retry a failed build through the language frontend

Scanning a 637-file WordPress plugin reported 1577 regions with 0 scanned
and `cpg_build_failed`. The build was not slow or out of memory -- it
failed in 42 seconds -- and `php2cpg` run directly on the same tree
produced a 4.3MB CPG with no errors at all.

The difference is that `joern-parse` detects the language and then shells
out to the frontend in a separate process. On this tree it got as far as
applying overlays and then threw, while still writing an unusable cpg.bin.
Joern's own output recommends the direct route for a large codebase:

  "Invoking CPG generator in a separate process... If you are importing a
   large codebase (and/or running into memory issues), please try:
   invoke the frontend: php2cpg -J-Xmx3072m /tmp/src -o cpg.bin"

So a failed `joern-parse` now retries through `<language>2cpg`, with the
driver passing the dominant region language. A language with no mapping
fails closed exactly as before -- no guessing at a frontend name.

This is the documented fallback rather than a workaround, and it was
diagnosed rather than assumed: the standalone build was run first, and only
after it succeeded on the identical tree was the retry added.

Gates byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-09 16:19:32 +02:00
co-authored by Claude Opus 5
parent 922ed84053
commit 9c50e17c11
3 changed files with 101 additions and 4 deletions
+40 -3
View File
@@ -47,6 +47,21 @@ QUERY_TIMEOUT_SECONDS = 300
CPG_HEAP_MB = 2048
HEAP_ENV = "OPENULTRASAST_CPG_HEAP_MB"
# `joern-parse` detects the language and then shells out to a frontend, and on a large tree it can fail where
# the frontend alone succeeds -- measured on a 637-file WordPress plugin, where joern-parse threw after
# applying overlays while `php2cpg` on the same tree produced a 4.3MB CPG with no errors. Joern's own message
# recommends the direct route for large codebases, so this is the documented fallback rather than a
# workaround. The names are Joern's frontend executables.
_FRONTENDS = {
"php": "php2cpg",
"python": "pysrc2cpg",
"javascript": "jssrc2cpg",
"typescript": "jssrc2cpg",
"java": "javasrc2cpg",
"c": "c2cpg",
"c_cpp": "c2cpg",
}
Runner = Callable[..., subprocess.CompletedProcess[str]]
@@ -116,8 +131,12 @@ class JoernBackend:
return has_cpg()
def build(self, root: Path) -> CpgResult | None:
"""Build a CPG for ``root``. ``None`` on any failure — the caller degrades, never guesses."""
def build(self, root: Path, *, language: str = "") -> CpgResult | None:
"""Build a CPG for ``root``. ``None`` on any failure — the caller degrades, never guesses.
``language`` lets a failed ``joern-parse`` retry through the frontend directly, which is what Joern
itself advises for a large codebase and what a 637-file WordPress plugin needed.
"""
parse = shutil.which("joern-parse")
if parse is None and os.environ.get("OPENULTRASAST_JOERN_PROBE", "").strip().lower() not in {"1", "on", "true", "yes"}:
return None
@@ -128,13 +147,31 @@ class JoernBackend:
if completed is None or completed.returncode != 0 or not cpg_path.is_file():
detail = (completed.stderr or completed.stdout or "")[-400:] if completed is not None else "timeout"
logger.warning("cpg build failed for %s: %s", root, detail)
return None
if not self._build_with_frontend(root, cpg_path, scratch, language):
return None
return CpgResult(
cpg_path=cpg_path,
run=lambda query, params: self.query(cpg_path, query, params),
run_batch=lambda query, requests: self.query_batch(cpg_path, query, requests),
)
def _build_with_frontend(self, root: Path, cpg_path: Path, scratch: Path, language: str) -> bool:
"""Retry the build through the language frontend alone. False when there is nothing to retry with."""
frontend = _FRONTENDS.get(language.lower())
if not frontend:
return False
binary = shutil.which(frontend)
if binary is None:
logger.warning("no frontend %s on PATH to retry the build for %s", frontend, root)
return False
completed = self._run([binary, self._heap_flag(), str(root), "-o", str(cpg_path)], timeout=self.build_timeout, cwd=scratch)
if completed is None or completed.returncode != 0 or not cpg_path.is_file():
detail = (completed.stderr or completed.stdout or "")[-400:] if completed is not None else "timeout"
logger.warning("%s also failed for %s: %s", frontend, root, detail)
return False
logger.info("built the cpg for %s with %s after joern-parse failed", root, frontend)
return True
def query_batch(self, cpg_path: Path, query: str, requests: Mapping[str, Mapping[str, object]]) -> dict[str, list[object]]:
"""Run ONE script invocation carrying many requests, keyed back to their ids.
+14 -1
View File
@@ -110,7 +110,12 @@ def scan_repository(
taxonomy = load_families()
build_started = time.monotonic()
cpg = backend.build(root)
# The dominant region language, so a failed `joern-parse` can retry through that frontend directly.
counts: dict[str, int] = {}
for region in regions:
counts[region.language] = counts.get(region.language, 0) + 1
dominant = max(counts, key=lambda name: (counts[name], name)) if counts else ""
cpg = _build(backend, root, dominant)
build_seconds = round(time.monotonic() - build_started, 2)
if cpg is None:
return ModelScanResult(
@@ -223,6 +228,14 @@ def scan_repository(
)
def _build(backend: Any, root: Path, language: str) -> Any:
"""Build through the backend, passing the language when the backend can use it."""
try:
return backend.build(root, language=language)
except TypeError: # a backend from before the retry existed, including every test double
return backend.build(root)
def _prefetched(rows: list[object]): # type: ignore[no-untyped-def]
"""A `run` that ignores the question and returns rows already fetched in the batch."""