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)
+65
View File
@@ -386,3 +386,68 @@ def test_a_gap_in_another_partition_does_not_demote_this_one(tmp_path):
assert php, result.question_outcomes
if all(str(d.get("census_language") or "") != "php" for d in result.degradations):
assert all(o.status == "completed" for o in php), [(o.status, o.reason) for o in php]
def _summary(rows):
"""What the engine now emits in place of itemised rows for files the change did not touch."""
unusable = sum(1 for r in rows if int(r["startLine"]) < 1 or int(r["endLine"]) < int(r["startLine"]))
return {"kind": "context_elsewhere", "locations": len(rows), "unusableExtents": unusable, "paths": sorted({r["path"] for r in rows})}
@pytest.mark.parametrize(
"elsewhere",
[
pytest.param([row("other.php"), row("other.php", "field", "f"), row("lib/deep.php", "hook", "h")], id="valid"),
pytest.param([row("other.php"), row("missing.php")], id="missing-file"),
pytest.param([row("other.php"), {**row("other.php", function="g"), "startLine": -1}], id="no-extent"),
pytest.param([row("../outside.php")], id="outside-root"),
],
)
@pytest.mark.parametrize("with_changed_row", [True, False])
def test_summarised_locations_decide_exactly_what_itemised_ones_did(tmp_path, elsewhere, with_changed_row):
"""Summarising unchanged-file locations in the engine must change nothing `affected_context` decides.
Itemised, one PHP request returned 1,845 locations and a push over a WordPress plugin never finished;
every location outside the changed files was only ever checked and then discarded, so the summary carries
exactly what that check reads.
"""
from openultrasast.model.contracts import QuestionIdentity
from openultrasast.model.regions import affected_context
(tmp_path / "source.php").write_text("readable\nchanged\n")
(tmp_path / "other.php").write_text("unchanged\n")
(tmp_path / "lib").mkdir()
(tmp_path / "lib" / "deep.php").write_text("unchanged\n")
(tmp_path.parent / "outside.php").write_text("outside the root\n")
question = QuestionIdentity("unit", "php", "sink.php", "run", "injection")
changed = [row("source.php", "callee")] if with_changed_row else []
head = [{"kind": "context_summary"}]
itemised = head + changed + elsewhere
summarised = head + changed + [_summary(elsewhere)]
old = affected_context(context(), [question], {question: itemised}, tmp_path)
new = affected_context(context(), [question], {question: summarised}, tmp_path)
assert new == old
def test_the_evidence_pass_names_the_changed_files_to_the_engine(tmp_path):
"""The engine can only summarise what the driver says is unchanged; with no safe list it itemises all."""
from openultrasast.model import scan
asked: list[dict[str, object]] = []
class Graph:
cpg_path = tmp_path / "cpg.bin"
def run_batch(self, kind, requests):
asked.extend(requests.values())
return {rid: [] for rid in requests}
work = scan._collect([region("sink.php", "php")])
context_rows: dict = {}
scan._evidence_pass(Graph(), work, "", context_rows=context_rows, context_files=("a.php", "b,c.php"))
assert asked and all(r["contextFilter"] == "true" and r["contextPaths"] == "a.php\nb,c.php" for r in asked)
asked.clear()
scan._evidence_pass(Graph(), work, "", context_rows=context_rows, context_files=None)
assert asked and all("contextFilter" not in r for r in asked)
assert scan._changed_files(context("x\ny.php")) is None
assert scan._changed_files(context("source.php")) == ("source.php",)