diff --git a/benchmarks/push/context_cost_probe.py b/benchmarks/push/context_cost_probe.py new file mode 100644 index 0000000..25bbd09 --- /dev/null +++ b/benchmarks/push/context_cost_probe.py @@ -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()) diff --git a/src/openultrasast/cpg/backend.py b/src/openultrasast/cpg/backend.py index c5f49ad..ec75f59 100644 --- a/src/openultrasast/cpg/backend.py +++ b/src/openultrasast/cpg/backend.py @@ -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]] diff --git a/src/openultrasast/cpg/queries/taint.sc b/src/openultrasast/cpg/queries/taint.sc index f6a80af..d1a728f 100644 --- a/src/openultrasast/cpg/queries/taint.sc +++ b/src/openultrasast/cpg/queries/taint.sc @@ -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))) diff --git a/src/openultrasast/model/regions.py b/src/openultrasast/model/regions.py index d420c29..3d62f6a 100644 --- a/src/openultrasast/model/regions.py +++ b/src/openultrasast/model/regions.py @@ -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", ""))) diff --git a/src/openultrasast/model/scan.py b/src/openultrasast/model/scan.py index a03c9bd..5b7f548 100644 --- a/src/openultrasast/model/scan.py +++ b/src/openultrasast/model/scan.py @@ -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 diff --git a/tests/test_cpg_queries_compile.py b/tests/test_cpg_queries_compile.py index 35ecb0f..08c6c7a 100644 --- a/tests/test_cpg_queries_compile.py +++ b/tests/test_cpg_queries_compile.py @@ -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) diff --git a/tests/test_model_change_context.py b/tests/test_model_change_context.py index e85828c..ce30692 100644 --- a/tests/test_model_change_context.py +++ b/tests/test_model_change_context.py @@ -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",)