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
+133
View File
@@ -0,0 +1,133 @@
"""Time the evidence pass with and without change context, on one graph, from the production request builder.
Named by the 2026-09-23 PHP transfer replay. The push path asks every evidence request for change context
(`contextEvidence`), and on Paid Memberships Pro that pass ran four portions in 13, 31 and 51 minutes each and
exhausted a 7,200 s case, where the census -- the same requests without context -- answers 3,935 in 45 s.
This builds the PHP graph ONCE, takes the first ``--requests`` taint requests exactly as the scan builds them,
and asks them in one invocation per mode. It proves it read its input (files, bytes, graph size, rows) before
reporting a time, and it saves every mode's rows so a change to the query can be checked for IDENTICAL output,
not merely for speed.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import time
from pathlib import Path
from openultrasast.cpg.backend import TIMING, JoernBackend
from openultrasast.mapping import analyze_entry_points
from openultrasast.model.contracts import ChangeContext, ChangedSpan, ExecutionBudget, QuestionIdentity
from openultrasast.model.regions import affected_context, regions_for
from openultrasast.model.scan import _collect, _grouped, _hook_callbacks
from openultrasast.model.shipped import declared_sources
from openultrasast.preprocess import build_file_target, enumerate_source_files
def digest(rows: object) -> str:
return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()[:16]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--requests", type=int, default=400)
parser.add_argument("--offsets", default="0", help="comma-separated start positions in the scan's request order")
parser.add_argument("--modes", default="plain,context")
parser.add_argument("--changed", default="", help="a changed file, for the `filtered` mode and the equivalence check")
args = parser.parse_args()
files = enumerate_source_files(args.root)
opened = sum(path.stat().st_size for path in files if path.is_file())
if not files or not opened:
raise SystemExit(f"probe read nothing under {args.root}: {len(files)} files, {opened} bytes")
targets = [build_file_target(args.root, path) for path in files]
found = regions_for(analyze_entry_points(args.root, targets), targets, shipped=declared_sources(args.root))
regions = [r for r in found if r.language == "php"]
hooks = _hook_callbacks(args.root, regions)
taint = _grouped(_collect(regions), hook_callbacks=hooks).get("taint", {})
ordered = list(taint.items())
print(f"read {len(files)} files, {opened} bytes; {len(regions)} php regions; {len(taint)} taint requests", flush=True)
backend = JoernBackend(query_timeout=6 * 3600, execution_budget=ExecutionBudget(time.monotonic() + 8 * 3600, 5.0))
started = time.monotonic()
graph = backend.build(args.root, language="php")
if graph is None:
raise SystemExit(f"graph build failed: {backend.last_failure}")
size = graph.cpg_path.stat().st_size
print(f"graph {size} bytes in {time.monotonic() - started:.1f}s", flush=True)
if size < 100_000:
raise SystemExit("graph is implausibly small; not measuring an empty graph")
record: dict[str, object] = {"files": len(files), "bytes": opened, "graph_bytes": size, "requests": args.requests, "modes": {}}
answers: dict[str, dict[str, object]] = {}
slices = [(int(o), dict(ordered[int(o) : int(o) + args.requests])) for o in args.offsets.split(",")]
for (offset, chosen), mode in [(s, m) for s in slices for m in args.modes.split(",")]:
mode = f"{mode}@{offset}"
files_in = sorted({str(p.get("file", "")) for p in chosen.values()})
print(f"{mode}: {len(chosen)} requests over {len(files_in)} files, first {files_in[:3]}", flush=True)
extra = {"evidenceOnly": "true", **({"contextEvidence": "true"} if mode.startswith(("context", "filtered")) else {})}
if mode.startswith("filtered"):
extra.update({"contextFilter": "true", "contextPaths": args.changed})
requests = {rid: {**dict(params), **extra} for rid, params in chosen.items()}
started = time.monotonic()
answer = backend._batch_once(graph.cpg_path, "taint", requests)
seconds = time.monotonic() - started
if answer is None:
raise SystemExit(f"mode {mode}: engine returned nothing after {seconds:.1f}s ({backend.last_failure})")
rows = {rid: answer[rid] for rid in requests if rid in answer}
costs = sorted((entry["ms"] for entry in answer.get(TIMING, []) if isinstance(entry, dict)), reverse=True)
summary = {
"seconds": round(seconds, 1),
"answered": len(rows),
"rows": sum(len(r) for r in rows.values()),
"digest": digest(rows),
"engine_ms_total": round(sum(costs)),
"engine_ms_top5": costs[:5],
}
record["modes"][mode] = summary # type: ignore[index]
(args.out.parent / f"{args.out.stem}-{mode}-rows.json").write_text(json.dumps(rows, sort_keys=True))
print(f"{mode}: {json.dumps(summary)}", flush=True)
args.out.write_text(json.dumps(record, indent=2) + "\n")
answers[mode] = rows
# The equivalence that matters: what `affected_context` decides from each form, for a change to `--changed`.
for offset, chosen in slices:
full, filtered = answers.get(f"context@{offset}"), answers.get(f"filtered@{offset}")
if full is None or filtered is None:
continue
lines = sum(1 for _ in (args.root / args.changed).open(errors="replace"))
change = ChangeContext(
"base",
"head",
(args.changed.encode().hex(),),
(),
(ChangedSpan(args.changed.encode().hex(), 1, lines, "head"),),
(),
(),
(),
(),
"filesystem-bytes-hex",
(),
)
identities = {rid: QuestionIdentity("unit", "php", str(chosen[rid].get("file", "")), rid, "injection") for rid in chosen}
decided = [
affected_context(change, list(identities.values()), {identities[rid]: rows[rid] for rid in rows}, args.root)
for rows in (full, filtered)
]
same = decided[0] == decided[1]
record[f"equivalent@{offset}"] = {"identical": same, "relationships": len(decided[0][0].relationships), "gaps": len(decided[0][1])}
print(f"equivalence@{offset}: {json.dumps(record[f'equivalent@{offset}'])}", flush=True)
args.out.write_text(json.dumps(record, indent=2) + "\n")
if not same:
raise SystemExit("filtered context changed what affected_context decides")
if graph.cleanup:
graph.cleanup()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -181,7 +181,7 @@ FRONTEND_BUILD_ATTEMPTS = 4
# across 2,500 requests made a 42.5MB request file of which 41.7MB was the same string over and over. Joern
# parsed that with ujson inside a 2GB heap and the whole batch died in ForkJoinPool, which the driver
# correctly reported as `query_failed` for all 2,500 regions -- a whole-repository scan that decided nothing.
_SHARED_REQUEST_FIELDS = ("hookCallbacks", "dispatchApply")
_SHARED_REQUEST_FIELDS = ("hookCallbacks", "dispatchApply", "contextFilter", "contextPaths")
Runner = Callable[..., subprocess.CompletedProcess[str]]
+37 -7
View File
@@ -52,6 +52,8 @@
evidenceOnly: String = "false",
hookCallbacks: String = "",
dispatchApply: String = "",
contextFilter: String = "",
contextPaths: String = "",
requests: String = "",
requestsFile: String = ""
) = {
@@ -96,7 +98,9 @@
fileS: String,
depthS: String,
boundedS: String,
contextS: String = ""
contextS: String = "",
contextFilterS: String = "",
contextPathsS: String = ""
): List[ujson.Obj] = {
// Word-boundary matching, never substring. `resolveUrl` contains `resolve`, so a bare-substring sanitizer
@@ -717,21 +721,45 @@
"startLine" -> m.lineNumber.getOrElse(-1), "endLine" -> m.lineNumberEnd.getOrElse(-1),
"relationship" -> kind)
val scoped = cpg.method.filterNot(_.isExternal).filter(m => inScope(m)).l
val methods = scoped.map(m => location(m, if (labeledMethods.exists(_.id == m.id)) "entry" else "callee"))
// Field carrying is deliberately same-file in the existing abstraction. Retain
// its file context conservatively, never claim every method carries the value.
val fieldFiles = if (fieldCarriedKind.isEmpty) Set.empty[String] else
cpg.call.nameExact(FIELD_ACCESS).filter(c => inScope(c.method)).l.map(_.method.filename).toSet
val fields = fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(m => location(m, "field")))
val callbacks = if (hookApply.isEmpty) Nil else cpg.call.filter(c => hookApply.contains(c.name))
.filter(c => inScope(c.method)).l.flatMap { call =>
val hook = call.argument.l.headOption.map(a => unquote(a.code)).getOrElse("")
hookTable.getOrElse(hook, Nil).flatMap(name => cpg.method.nameExact(name).filterNot(_.isExternal).l)
}.distinct
val hooks = callbacks.map(m => location(m, "hook"))
// Export sanitizer locations as guard context, not proof of discharge. Removed
// guards are retained through base spans and unchanged-line correspondence.
val guards = scoped.filter(_.ast.l.exists(sanitizesHere)).map(m => location(m, "guard"))
val located: List[(io.shiftleft.codepropertygraph.generated.nodes.Method, String)] =
scoped.map(m => m -> (if (labeledMethods.exists(_.id == m.id)) "entry" else "callee")) ++
fieldFiles.toList.flatMap(f => methodsIn(f).filterNot(_.isExternal).map(_ -> "field")) ++
callbacks.map(_ -> "hook") ++
scoped.filter(_.ast.l.exists(sanitizesHere)).map(_ -> "guard")
// ONLY THE CHANGED FILES are itemised when the driver says which they are. The consumer attaches a
// location to a change only when the location's file is one the change touched, and discards every
// other row after checking it names a real file with a sane extent. Itemised anyway, a request whose
// scope reaches a vendored SDK returned 1,845 rows, 200 of them 9.4 MB, and the whole-repository pass
// on a WordPress plugin never finished a push. Everything else collapses into ONE row carrying exactly
// what that check reads: the distinct files and how many locations had no usable extent.
val itemise = contextFilterS != "true"
// Newline-separated: a path may hold a comma, and the driver never filters when one holds a newline.
val changedFiles = contextPathsS.split("\n").map(_.trim).filter(_.nonEmpty).toList
def touched(m: io.shiftleft.codepropertygraph.generated.nodes.Method): Boolean =
itemise || changedFiles.exists(p => m.filename == p || m.filename.endsWith("/" + p))
val (kept, elsewhere) = located.partition { case (m, _) => touched(m) }
val locations = kept.map { case (m, relationship) => location(m, relationship) }
val elsewhereRows =
if (elsewhere.isEmpty) Nil
else {
val unusable = elsewhere.count { case (m, _) =>
val start = m.lineNumber.getOrElse(-1)
start < 1 || m.lineNumberEnd.getOrElse(-1) < start
}
List(ujson.Obj("kind" -> "context_elsewhere", "locations" -> elsewhere.size, "unusableExtents" -> unusable,
"paths" -> ujson.Arr.from(elsewhere.map(_._1.filename).distinct.sorted)))
}
// Reachability remains bounded. External/dynamic calls and calls outside the
// current reachability set cannot establish a complete negative for a change.
// A modeled argument does not summarize its consumer: unknown(req.body),
@@ -761,7 +789,7 @@
else if (missing)
List(ujson.Obj("kind" -> "context_boundary", "reason" -> "dynamic_external_or_depth_context_unresolved:contributor-scan"))
else Nil
ujson.Obj("kind" -> "context_summary") :: (methods ++ fields ++ hooks ++ guards ++ boundaries)
ujson.Obj("kind" -> "context_summary") :: (locations ++ elsewhereRows ++ boundaries)
}
return summary :: (perSink ++ contextRows)
}
@@ -913,7 +941,9 @@
field("file"),
field("callDepth"),
field("boundedSinks"),
field("contextEvidence")
field("contextEvidence"),
if (field("contextFilter").nonEmpty) field("contextFilter") else contextFilter,
if (field("contextPaths").nonEmpty) field("contextPaths") else contextPaths
): _*
)
println(ujson.write(ujson.Obj("id" -> id, "rows" -> rows, "ms" -> ((System.nanoTime() - started) / 1000000L).toDouble)))
+42 -1
View File
@@ -194,6 +194,37 @@ AMBIGUOUS_CORRESPONDENCE = "ambiguous_line_correspondence"
REACHABILITY_BOUNDED = "dynamic_external_or_depth_context_unresolved"
def _elsewhere_unusable(row: Mapping[str, object], root: Path, checked: dict[str, bool]) -> bool:
"""Whether a summarised set of locations holds one the itemised check would have rejected.
The same rejections as an itemised `context_method` row: a file that is not a real file under the root,
or an extent that is missing or inverted. Each file is checked once per call, because the summary for
every family of a region names the same files.
"""
try:
if int(str(row.get("unusableExtents", 0))) > 0:
return True
except ValueError:
return True
paths = row.get("paths")
if not isinstance(paths, list):
return True
resolved_root = root.resolve()
for raw in paths:
text = str(raw)
if text not in checked:
try:
path = Path(text)
absolute = path if path.is_absolute() else root / path
absolute.resolve().relative_to(resolved_root)
checked[text] = absolute.is_file()
except (ValueError, OSError):
checked[text] = False
if not checked[text]:
return True
return False
def affected_context(
context: ChangeContext,
questions: Sequence[QuestionIdentity],
@@ -224,6 +255,7 @@ def affected_context(
# ambiguous anchor remains uncomparable.
inherited_gap = any(not gap.endswith(":" + AMBIGUOUS_CORRESPONDENCE) for gap in gaps)
renamed = {context.decode_path(r.base_path): context.decode_path(r.head_path) for r in context.renames}
checked: dict[str, bool] = {}
for identity in questions:
if execution_budget is not None and time.monotonic() >= execution_budget.deadline_monotonic:
gaps.append("change_context_deadline_exhausted")
@@ -233,7 +265,9 @@ def affected_context(
local_gaps = []
if not any(r.get("kind") == "context_summary" for r in rows):
local_gaps.append("context_projection_unavailable:contributor-scan")
elif not any(r.get("kind") == "context_method" for r in rows) and not any(r.get("kind") == "context_boundary" for r in rows):
elif not any(r.get("kind") in ("context_method", "context_elsewhere") for r in rows) and not any(
r.get("kind") == "context_boundary" for r in rows
):
# Only when the query offered no account of its own. A query that already said why its
# scope is empty -- a named function this file does not define, most often -- has given
# the specific reason, and adding a generic one on top buries it.
@@ -243,6 +277,13 @@ def affected_context(
if row.get("kind") == "context_boundary":
reason = str(row.get("reason", "context_unresolved"))
(recorded_only if reason.startswith(REACHABILITY_BOUNDED) else local_gaps).append(reason)
if row.get("kind") == "context_elsewhere":
# Locations in files the change did not touch, summarised by the engine. None of them can be
# attached to a change, so the only thing to do with them is what was always done before they
# were discarded: check each names a real file under the root and had a usable extent.
if _elsewhere_unusable(row, root, checked):
local_gaps.append("context_location_unavailable")
continue
if row.get("kind") != "context_method":
continue
path = Path(str(row.get("path", "")))
+20 -4
View File
@@ -340,6 +340,7 @@ def _scan_repository_impl(
hooks,
degradations=degradations,
context_rows=context_rows if change_context is not None else None,
context_files=_changed_files(change_context) if change_context is not None else None,
unit=unit,
)
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
@@ -878,6 +879,17 @@ def _sink_weights(
return weights
def _changed_files(context: ChangeContext) -> tuple[str, ...] | None:
"""The head-side files a context location can be attached to, exactly the set `affected_context` reads.
``None`` when one cannot be sent intact -- a newline in a path -- so the engine itemises everything.
"""
changed = {context.decode_path(p) for p in context.changed_paths} | {context.decode_path(r.head_path) for r in context.renames}
if any("\n" in path or not path for path in changed):
return None
return tuple(sorted(changed))
def _request_families(work: Sequence[tuple[str, ScanRegion, ArbiterSpec]]) -> dict[str, str]:
"""The family of each request id: the unit the sizer learns a cost rate for."""
return {rid: str(getattr(spec, "family", "")) for rid, _region, spec in work}
@@ -1224,6 +1236,7 @@ def _evidence_pass(
*,
degradations: list[Mapping[str, object]] | None = None,
context_rows: dict[QuestionIdentity, list[Mapping[str, object]]] | None = None,
context_files: tuple[str, ...] | None = None,
unit: str = "repository",
) -> tuple[dict[tuple[str, str, str], Evidence], int]:
"""The evidence vector of every taint pair in ``work``, keyed by (path, function, family).
@@ -1234,10 +1247,13 @@ def _evidence_pass(
grouped_taint = _grouped(list(work), hook_callbacks=hooks).get("taint", {})
if not grouped_taint:
return {}, 0
requests = {
rid: {**dict(params), "evidenceOnly": "true", **({"contextEvidence": "true"} if context_rows is not None else {})}
for rid, params in grouped_taint.items()
}
# With change context, the engine itemises locations only in the files the change touched and summarises
# the rest, because those are the only locations `affected_context` can attach. `None` means the change's
# files cannot be named safely, and then every location is itemised as before.
context = {"contextEvidence": "true"} if context_rows is not None else {}
if context and context_files is not None:
context.update({"contextFilter": "true", "contextPaths": "\n".join(context_files)})
requests = {rid: {**dict(params), "evidenceOnly": "true", **context} for rid, params in grouped_taint.items()}
# Portioned by request count, because evidence has no per-request weight yet -- it is the pass that
# PRODUCES the weights. It is cheap per request, 17,700 in 44 s, and it was still one all-or-nothing
# batch: a 22,125-request evidence pass on a plugin's push transaction exceeded its ceiling under load and
+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",)