fix(scan): itemise change context only in the files a change touched

The PHP transfer replay (pre-push 13.3) spent its whole 7,200 s case in the
head side's evidence pass. A probe on one PMPro graph named the cause: not
compute -- context adds ~20% engine time -- but OUTPUT. Each evidence request
listed every method its scope reached, up to 1,845 rows; 200 requests into
the bundled Braintree SDK returned 52,831 rows, 9.4 MB, against 211 rows
without context, over 22,125 requests in a 3 GB container.

`affected_context` only ever attaches a location in a changed file, and
every other row was checked (real file under the root, usable extent) and
discarded. The scan now sends the changed files; the engine itemises those
and folds the rest into one `context_elsewhere` row carrying exactly what
that check reads. With no safe file list the engine itemises everything,
as before.

Equivalence: unit controls over valid, missing-file, no-extent and
outside-root cases, with and without a changed-file row; a real-Joern
control; and on PMPro itself, 2,440 relationships and zero gaps decided
identically from both forms, 52,831 rows down to 3,241 with the most
referenced file as the change. Probe: benchmarks/push/context_cost_probe.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-23 12:19:35 +02:00
co-authored by Claude Opus 5.5
parent cda679cb3b
commit 3c195c9369
7 changed files with 351 additions and 13 deletions
+53
View File
@@ -626,3 +626,56 @@ def test_the_taint_query_streams_one_line_per_request(tmp_path: Path) -> None:
answers = [json.loads(line) for line in lines[1:]]
assert all(isinstance(a.get("ms"), (int, float)) and a["ms"] >= 0 for a in answers), answers
assert [t["id"] for t in assembled["__timing__"]] == ["0", "1"]
@pytest.mark.skipif(_joern() is None, reason="joern is not installed on this machine")
def test_context_locations_outside_the_changed_files_are_summarised(tmp_path: Path) -> None:
"""Only the changed files are itemised; every other location collapses into one checkable summary.
Itemised, one PHP request returned 1,845 locations and a push over a WordPress plugin never finished.
"""
frontend = __import__("shutil").which("jssrc2cpg")
if frontend is None:
pytest.skip("jssrc2cpg is not installed")
(tmp_path / "src").mkdir()
(tmp_path / "src" / "a.js").write_text(
"const h = require('./b');\nfunction handler(req, res) { return h.helper(req.body.x); }\nmodule.exports = handler;\n"
)
(tmp_path / "src" / "b.js").write_text("function helper(x) { return eval(x); }\nmodule.exports = { helper };\n")
cpg = tmp_path / "cpg.bin"
subprocess.run([frontend, str(tmp_path / "src"), "-o", str(cpg)], capture_output=True, text=True, timeout=600, check=False)
if not cpg.is_file():
pytest.skip("could not build a sample cpg")
# Repository-wide scope, so the context reaches methods in both files. A `require`d helper is not a
# resolved call edge in the JavaScript graph, so a handler-scoped request would never leave `a.js`.
request = {"sources": "req.body", "sinks": "eval", "evidenceOnly": "true", "contextEvidence": "true"}
requests = tmp_path / "requests.json"
requests.write_text(json.dumps({"whole": request, "filtered": {**request, "contextFilter": "true", "contextPaths": "b.js"}}))
done = subprocess.run(
[
_joern() or "joern",
"--script",
str((QUERIES / "taint.sc").resolve()),
"--param",
f"cpgFile={cpg}",
"--param",
f"requestsFile={requests}",
],
capture_output=True,
text=True,
timeout=900,
check=False,
)
assert done.returncode == 0, (done.stderr or done.stdout or "")[-500:]
from openultrasast.cpg.backend import extract_payload
payload = extract_payload(done.stdout)
assert isinstance(payload, dict)
whole = [r for r in payload["whole"] if r.get("kind") == "context_method"]
kept = [r for r in payload["filtered"] if r.get("kind") == "context_method"]
summary = [r for r in payload["filtered"] if r.get("kind") == "context_elsewhere"]
assert {r["path"] for r in whole} >= {"a.js", "b.js"}, whole # the unfiltered form still itemises everything
assert kept and all(r["path"] == "b.js" for r in kept), kept
assert kept == [r for r in whole if r["path"] == "b.js"], "the changed file's locations must be unchanged"
assert len(summary) == 1 and summary[0]["paths"] == sorted({r["path"] for r in whole if r["path"] != "b.js"})
assert summary[0]["locations"] == len(whole) - len(kept)