From 66357ffea19f306b73f7e32db0fcba86031aea34 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:31:14 +0200 Subject: [PATCH] pre-push(17.5): say when the engine cannot finish, and offer it in the background --engine inline|background|off (hook: OUSAST_PUSH_ENGINE). background returns after the quick tier and starts one detached explicit replay of the same base and head (push/background.py), one at a time per artifact directory; its artifact lands under /engine//.json and the next run prints "Engine result for from the previous push" once. A deadline miss says so in one line naming the deadline and the background option. No engine speed work. Requirement 9.5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo --- .kiro/specs/pre-push-safety-net/tasks.md | 3 +- .../measurements/2026-09-08-module-audit.json | 14 +- ops/pre-push | 3 +- src/openultrasast/cli.py | 25 ++- src/openultrasast/push/background.py | 167 ++++++++++++++++++ src/openultrasast/push/explain.py | 1 + src/openultrasast/push/report.py | 49 ++++- src/openultrasast/push/runner.py | 58 +++++- tests/test_push_background.py | 82 +++++++++ 9 files changed, 392 insertions(+), 10 deletions(-) create mode 100644 src/openultrasast/push/background.py create mode 100644 tests/test_push_background.py diff --git a/.kiro/specs/pre-push-safety-net/tasks.md b/.kiro/specs/pre-push-safety-net/tasks.md index a03395b..59d275d 100644 --- a/.kiro/specs/pre-push-safety-net/tasks.md +++ b/.kiro/specs/pre-push-safety-net/tasks.md @@ -648,10 +648,11 @@ Preserve all completed task evidence below. - Evidence: unit tests on the comparison and admission; a live engine run if the host allows one (free memory >= 3 GB). - _Done 2026-10-02:_ `ChangeContext.added_paths` records Git status-A paths (rename targets excluded). In `_compare_evidence`, after every existing context/head/deadline check, an operation in an added file is `new`/`file_added_in_head` when the claim is entailed, undischarged and has change evidence, and no base answer holds the same operation (else `operation_moved_within_change`); an unpaired add/delete still blocks as `change_context_unresolved`. Admission accepts `(new, file_added_in_head)`; with the empty registry such a candidate's only veto is `capability_unavailable`, and the terminal now shows it under "Engine: N finding(s) this push introduced. Advisory ... not an alert." Any comparison, evidence or location veto keeps a candidate out of that line. Tests: `tests/test_push_new_file.py` (9). Live engine evidence depends on host memory (17.7). Gates: 2007 passed, 42 skipped; ruff, format, mypy clean. - _Requirements: 9.4, 3.4_ -- [ ] 17.5 Say when the engine cannot finish, and offer it in the background +- [x] 17.5 Say when the engine cannot finish, and offer it in the background - One line when the engine did not finish (deadline, engine not installed). `--engine background` starts the engine detached after the quick tier, writes its result under the artifact directory and, on the next run, prints "engine result for from the previous push". One background engine at a time; `--engine off` skips it with a stated line. No speed work. - Files: `src/openultrasast/push/background.py` (new), `src/openultrasast/push/runner.py`, `src/openultrasast/cli.py`, `ops/pre-push`. Tests: `tests/test_push_background.py` (new). - Evidence: a hook push with `OUSAST_PUSH_ENGINE=background` followed by a second push showing the previous result. + - _Done 2026-10-02:_ `--engine inline|background|off` (hook: `OUSAST_PUSH_ENGINE`) and `--background-deadline` (default 900 s). `off` and `background` run the quick tier and record `engine_off`/`engine_background` with a plain skip line; the deadline line names the deadline and the background option. New `push/background.py` starts one detached explicit replay (`start_new_session`, stdin closed, output to a log) per artifact directory and repository (lock file with a live-pid check: a second start reports busy), writes its artifact under `/engine//.json`, and the next run prints "Engine result for from the previous push: ..." once, then marks it shown. Not usable through the Docker wrapper (the container exits with the hook); documented in 17.6. Tests: `tests/test_push_background.py` (4, one launching a real detached run). Gates: 2011 passed, 42 skipped; ruff, format, mypy clean. - _Requirements: 9.5, 4.1, 4.2_ - [ ] 17.6 Install, Docker and documentation for a first-time user - `ousast pre-push install|uninstall [--force] [--docker] [REPO]` ships the hook as package data, honours `core.hooksPath`, refuses an existing hook unless `--force` (which chains it as `pre-push.before-ousast`), and works from a pip install. `ops/ousast-docker` is a hook-callable script (not a shell function) that passes stdin and the artifact path through. The `openultrasast:dev` image is rebuilt only if `df -h /` shows at least 4 GB free; otherwise the rebuild command is documented and the record says it was not rebuilt. `docs/scanning.md` gains a first-run guide, a glossary of skip reasons, a GitHub Actions recipe and full `--help` text; README tiers 1 and 2 updated, pulling `ghcr.io/norandom/openultrasast:2.0.1` where the wrapper allows. diff --git a/benchmarks/measurements/2026-09-08-module-audit.json b/benchmarks/measurements/2026-09-08-module-audit.json index 0eb8b32..d46b7b3 100644 --- a/benchmarks/measurements/2026-09-08-module-audit.json +++ b/benchmarks/measurements/2026-09-08-module-audit.json @@ -1,7 +1,7 @@ { - "modules": 153, + "modules": 154, "counts": { - "load_bearing": 148, + "load_bearing": 149, "standalone_capability": 5 }, "orphaned": [], @@ -1196,6 +1196,7 @@ "importers": [ "cli", "push.assistance", + "push.background", "push.eligibility", "push.policy", "push.report", @@ -1213,6 +1214,14 @@ "push.runner" ] }, + { + "module": "push.background", + "classification": "load_bearing", + "reason": "reachable from gate, map_gate, pair_gate, cli", + "importers": [ + "push.runner" + ] + }, { "module": "push.cache", "classification": "load_bearing", @@ -1278,6 +1287,7 @@ "reason": "pre-push-safety-net Req 5.1/5.2/5.5/7.3: compact output and complete artifacts; runner integration pending", "importers": [ "push.assistance", + "push.background", "push.runner" ] }, diff --git a/ops/pre-push b/ops/pre-push index 1504ce6..ff0eb7e 100755 --- a/ops/pre-push +++ b/ops/pre-push @@ -19,7 +19,8 @@ remote_url=$2 set -- pre-push . --artifact "$artifact_dir/push-$$.json" \ --deadline "${OUSAST_PUSH_DEADLINE:-30}" \ --mode "${OUSAST_PUSH_MODE:-advisory}" \ - --incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" + --incomplete-coverage "${OUSAST_INCOMPLETE_COVERAGE:-allow}" \ + --engine "${OUSAST_PUSH_ENGINE:-inline}" if [ -n "${OUSAST_COMPARISON_BASE:-}" ]; then set -- "$@" --comparison-base "$OUSAST_COMPARISON_BASE" fi diff --git a/src/openultrasast/cli.py b/src/openultrasast/cli.py index 787adbe..a68ec81 100644 --- a/src/openultrasast/cli.py +++ b/src/openultrasast/cli.py @@ -154,6 +154,19 @@ def _main(argv: list[str] | None) -> int: replay_parser.add_argument("--max-regions", type=int, default=500) replay_parser.add_argument("--mode", choices=("advisory", "blocking"), default="advisory") replay_parser.add_argument("--incomplete-coverage", choices=("allow", "block"), default="allow") + replay_parser.add_argument( + "--engine", + choices=("inline", "background", "off"), + default="inline", + help="inline (default): run the Joern engine inside the deadline; background: return after the quick rules and " + "run the engine detached, showing its result on the next run; off: quick rules only", + ) + replay_parser.add_argument( + "--background-deadline", + type=float, + default=900.0, + help="seconds the detached engine run of --engine background may take (default 900)", + ) replay_parser.add_argument( "--experimental-declarations", type=Path, @@ -459,6 +472,8 @@ def _pre_push(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: head=args.head, record_vetoes=args.experimental_record_vetoes, declarations=args.experimental_declarations, + engine=args.engine, + background_deadline=args.background_deadline, **options, ) else: @@ -481,7 +496,15 @@ def _pre_push(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int: def updates() -> str: return (prior_data if prior_data is not None else stream.read(1024 * 1024 + 1)).decode("utf-8") - delivery = push(args.path, updates=updates, remote_name=args.remote[0], remote_url=args.remote[1], **options) + delivery = push( + args.path, + updates=updates, + remote_name=args.remote[0], + remote_url=args.remote[1], + engine=args.engine, + background_deadline=args.background_deadline, + **options, + ) except ValueError as error: parser.error(str(error)) print(delivery.text, end="", flush=True) diff --git a/src/openultrasast/push/background.py b/src/openultrasast/push/background.py new file mode 100644 index 0000000..c776330 --- /dev/null +++ b/src/openultrasast/push/background.py @@ -0,0 +1,167 @@ +"""Run the engine after the push, and show its result on the next one (task 17.5, Requirement 9.5). + +The engine cannot finish a small repository inside a 30 s hook deadline (the 2026-10-02 audit +measured 260 s on 26 files). Until graph reuse makes it fast, `--engine background` lets the hook +return after the quick tier and starts one detached explicit replay of the same base and head. That +replay writes an ordinary artifact under the artifact directory; the next run in the same repository +prints "engine result for from the previous push" once, then marks it shown. + +One background engine at a time per artifact directory: the host this was built on has 7 GB, and +two Joern JVMs is what this project has seen killed. No speed work happens here. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import subprocess +import sys +from collections.abc import Mapping +from pathlib import Path + +from openultrasast.push.report import CAPABILITY_VETOES + +_LOCK = "running.json" + + +def results_dir(artifact: Path, repository: Path) -> Path: + """Per-repository directory beside the artifact; the repository path is hashed, never stored raw.""" + key = hashlib.sha256(str(repository.resolve()).encode()).hexdigest()[:16] + return artifact.parent / "engine" / key + + +def _alive(pid: object) -> bool: + if not isinstance(pid, int) or pid <= 0: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def start( + repository: Path, + *, + base: str, + head: str, + artifact: Path, + deadline_seconds: float, + max_regions: int = 500, + cache_dir: Path | None = None, +) -> dict[str, object]: + """Start one detached engine replay of base..head; never waits for it and never raises.""" + directory = results_dir(artifact, repository) + try: + directory.mkdir(parents=True, exist_ok=True) + lock = directory / _LOCK + try: + running = json.loads(lock.read_text()) + except (OSError, ValueError): + running = {} + if isinstance(running, dict) and _alive(running.get("pid")): + return {"status": "busy", "head": str(running.get("head", "")), "directory": str(directory)} + target = directory / f"{head}.json" + log = directory / f"{head}.log" + command = [ + sys.executable, + "-m", + "openultrasast.cli", + "pre-push", + str(repository.resolve()), + "--base", + base, + "--head", + head, + "--artifact", + str(target), + "--deadline", + f"{deadline_seconds:g}", + "--max-regions", + str(max_regions), + "--engine", + "inline", + ] + if cache_dir is not None: + command.extend(("--cache-dir", str(cache_dir))) + with open(log, "wb") as output: + process = subprocess.Popen( # noqa: S603 -- our own CLI with immutable commit ids, no shell + command, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + cwd=str(repository.resolve()), + start_new_session=True, + close_fds=True, + ) + lock.write_text(json.dumps({"pid": process.pid, "head": head, "base": base})) + return { + "status": "started", + "head": head, + "deadline_seconds": deadline_seconds, + "artifact": str(target), + "log": str(log), + } + except Exception as error: # noqa: BLE001 -- a background start that fails is a stated gap, never a block + return {"status": "failed", "reason": type(error).__name__} + + +def _summary(data: Mapping[str, object]) -> dict[str, object]: + admission = data.get("admission") + result = data.get("result") + admission = admission if isinstance(admission, Mapping) else {} + result = result if isinstance(result, Mapping) else {} + defects = admission.get("defects") + reasons = admission.get("coverage_reasons") + reasons = [str(r) for r in reasons] if isinstance(reasons, list) else [] + advisory = [] + dispositions = admission.get("dispositions") + for item in dispositions if isinstance(dispositions, list) else []: + try: + delta = item["candidate"]["delta"] + op = delta["head_operation"] or {} + if ( + not item["admitted"] + and delta["novelty"] in ("new", "worsened") + and set(item["reasons"]) <= CAPABILITY_VETOES + and op + and delta["witness"] + ): + advisory.append({"family": delta["family"], "path": op["path"], "line": op["line"], "novelty": delta["novelty"]}) + except (KeyError, TypeError): + continue + unique = list({(a["family"], a["path"], a["line"]): a for a in advisory}.values()) + return { + "alerts": len(defects) if isinstance(defects, list) else 0, + "advisory": unique, + "coverage": result.get("coverage_status", "unknown"), + "finished": "deadline_exhausted" not in reasons, + "engine_ran": not any(r in ("cpg_build_failed", "cpg_unavailable") for r in reasons), + } + + +def previous(artifact: Path, repository: Path) -> list[dict[str, object]]: + """Finished background results not yet shown for this repository; each is shown exactly once.""" + directory = results_dir(artifact, repository) + shown: list[dict[str, object]] = [] + try: + candidates = sorted(directory.glob("*.json"), key=lambda p: p.stat().st_mtime) + except OSError: + return shown + for path in candidates: + if path.name == _LOCK or path.with_suffix(".shown").exists(): + continue + try: + data = json.loads(path.read_text()) + except (OSError, ValueError): + continue # still being written, or unreadable: leave it for the next run + if not isinstance(data, dict): + continue + shown.append({"head": path.stem, "artifact": str(path), **_summary(data)}) + with contextlib.suppress(OSError): + path.with_suffix(".shown").touch() + return shown diff --git a/src/openultrasast/push/explain.py b/src/openultrasast/push/explain.py index 39a39c2..2d27491 100644 --- a/src/openultrasast/push/explain.py +++ b/src/openultrasast/push/explain.py @@ -58,6 +58,7 @@ GLOSSARY: dict[str, str] = { "regions_truncated": "only the highest-ranked regions were examined (--max-regions)", "files_unparsed": "some files could not be parsed by the engine", "engine_off": "engine checks were switched off for this run (--engine off)", + "engine_background": "engine checks run in the background after this push; the next push shows their result", "cross_partition_semantics_unresolved": "flows between languages in this repository are not followed", "resolution": "the pushed ref could not be resolved ({detail})", "push_input_failed": "the push input could not be read ({detail})", diff --git a/src/openultrasast/push/report.py b/src/openultrasast/push/report.py index 3787b18..dc7c5ea 100644 --- a/src/openultrasast/push/report.py +++ b/src/openultrasast/push/report.py @@ -47,6 +47,8 @@ class PushReport: experimental: Mapping[str, object] = field(default_factory=dict) # The quick-rule tier (task 17.3): one payload per comparison it ran for; advisory only. quick: tuple[Mapping[str, object], ...] = () + # The background engine (task 17.5): what this run started, and earlier results shown now. + background: Mapping[str, object] = field(default_factory=dict) def __post_init__(self) -> None: object.__setattr__(self, "model_assistance", MappingProxyType(dict(self.model_assistance))) @@ -137,6 +139,7 @@ def _write_artifact(report: PushReport, target: Path, temporary: Path, connectio "resolution": report.resolution.to_payload() if report.resolution else None, "experimental": dict(report.experimental), "quick_tier": [dict(item) for item in report.quick], + "engine_background": dict(report.background), } json.dump(payload, stream, ensure_ascii=True, allow_nan=False, indent=2) stream.write("\n") @@ -222,7 +225,7 @@ def _experimental_line(experimental: Mapping[str, object]) -> str: # Vetoes that say only "this capability is not qualified yet". A candidate whose novelty is established # and whose only vetoes are these is shown as an advisory engine finding (task 17.4, Requirement 9.4); # any comparison, evidence or location veto keeps it out of the terminal. -_CAPABILITY_VETOES = frozenset( +CAPABILITY_VETOES = frozenset( { "capability_unavailable", "capability_disabled", @@ -243,7 +246,7 @@ def _advisory_engine_lines(report: PushReport) -> list[str]: if ( disposition.admitted or delta.novelty not in ("new", "worsened") - or not set(disposition.reasons) <= _CAPABILITY_VETOES + or not set(disposition.reasons) <= CAPABILITY_VETOES or op is None or delta.witness is None or delta.defect_id in shown @@ -263,6 +266,46 @@ def _advisory_engine_lines(report: PushReport) -> list[str]: return lines +def _previous_engine_lines(background: Mapping[str, object]) -> list[str]: + """Background engine results from an earlier push, shown once (task 17.5).""" + lines: list[str] = [] + earlier = background.get("previous") + for item in earlier if isinstance(earlier, list) else []: + if not isinstance(item, Mapping): + continue + advisory = item.get("advisory") + found = [a for a in advisory if isinstance(a, Mapping)] if isinstance(advisory, list) else [] + state = ( + "the engine did not run (Joern unavailable)" + if not item.get("engine_ran") + else f"{item.get('alerts', 0)} alert(s), {len(found)} advisory engine finding(s), coverage {item.get('coverage')}" + + ("" if item.get("finished") else ", did not finish within its deadline") + ) + head = str(item.get("head", ""))[:12] + lines.append(f"Engine result for {_short(head)} from the previous push: {_short(state, 300)}.") + for entry in found[:3]: + lines.append(f"- {_short(str(entry.get('family')))} at {_short(str(entry.get('path')), 4096)}:{entry.get('line')}") + lines.append(" Details: " + _short(str(item.get("artifact", "")), 4096)) + return lines + + +def _background_lines(background: Mapping[str, object]) -> list[str]: + status = background.get("status") + head = _short(str(background.get("head", ""))[:12]) + if status == "started": + return [ + f"Engine: running in the background for {head} (deadline {background.get('deadline_seconds')} s); " + "the next push shows its result." + ] + if status == "busy": + return [f"Engine: a background engine run for {head} is still busy, so none was started for this push."] + if status == "failed": + return [f"Engine: the background run could not be started ({_short(str(background.get('reason')))})."] + if status == "not_started": + return [f"Engine: no background run was started ({_short(str(background.get('reason')))})."] + return [] + + # Quick-rule matches shown per comparison; the artifact keeps every one. _QUICK_LINES = 5 @@ -329,6 +372,7 @@ def render_report(report: PushReport, *, artifact: Path | None, error: str | Non ) if len(defects) > 3: lines.append(f"{len(defects) - 3} more actionable defects retained in the detailed result.") + lines.extend(_previous_engine_lines(report.background)) lines.extend(_advisory_engine_lines(report)) for payload in report.quick: lines.extend(_quick_lines(payload)) @@ -364,6 +408,7 @@ def render_report(report: PushReport, *, artifact: Path | None, error: str | Non notices.append("Details could not be saved. Choose an artifact path outside the analyzed repository.") elif error: notices.append("Details could not be saved. Retry with a writable artifact path and available reporting time.") + lines.extend(_background_lines(report.background)) if notices: lines.append("Notice: " + " ".join(notices)) if report.experimental: diff --git a/src/openultrasast/push/runner.py b/src/openultrasast/push/runner.py index de65b12..7f9a864 100644 --- a/src/openultrasast/push/runner.py +++ b/src/openultrasast/push/runner.py @@ -268,7 +268,7 @@ def _analyze( resolved: PushComparison | None = None, record_vetoes: bool = False, declarations: Path | None = None, - engine: Literal["inline", "off"] = "inline", + engine: Literal["inline", "off", "background"] = "inline", quick_basis: Basis = "comparison", ) -> PushReport: """Run a single explicit comparison; exit policy never stands for coverage. @@ -390,8 +390,8 @@ def _analyze( quick = QuickTierResult("failed", quick_basis, reason=type(error).__name__) reasons.append("quick_rules_failed:" + type(error).__name__) stage_started = _stage(timings, stage, stage_started) - if engine == "off": - reasons.append("engine_off") + if engine != "inline": + reasons.append("engine_" + engine) if engine == "inline": stage = "head" head_scan = scan_repository( @@ -523,6 +523,40 @@ def _last_commit_quick( ) +def _background( + repository: Path, + pending: Sequence[tuple[str | None, str]], + artifact: Path, + deadline: float, + max_regions: int, + cache_dir: Path | None, +) -> dict[str, object]: + """Earlier background results to show, and at most one new background engine run (task 17.5).""" + from openultrasast.push import background + + state: dict[str, object] = {"previous": background.previous(artifact, repository)} + ready = [(base, head) for base, head in pending if base is not None] + if ready: + base, head = ready[0] + assert base is not None + state.update( + background.start( + repository, + base=base, + head=head, + artifact=artifact, + deadline_seconds=deadline, + max_regions=max_regions, + cache_dir=cache_dir, + ) + ) + if len(ready) > 1: + state["not_started"] = [h for _, h in ready[1:]] # one engine at a time + elif pending: + state.update(status="not_started", reason="no base revision to compare against") + return state + + def _deliver(repository: Path, report: PushReport, artifact: Path, budget: ExecutionBudget) -> ReportDelivery: if artifact.resolve().is_relative_to(repository.resolve()): error = "artifact_inside_repository" @@ -543,6 +577,8 @@ def replay( model_config: Path | None = None, record_vetoes: bool = False, declarations: Path | None = None, + engine: Literal["inline", "off", "background"] = "inline", + background_deadline: float = 900.0, ) -> ReportDelivery: config = config or PushConfig() if declarations is not None and not record_vetoes: @@ -560,7 +596,10 @@ def replay( execution_budget=budget, record_vetoes=record_vetoes, declarations=declarations, + engine=engine, ) + pending = [(base, head)] if engine == "background" else [] + report = replace(report, background=_background(repository, pending, artifact, background_deadline, max_regions, cache_dir)) from openultrasast.push.assistance import assist report = assist(report, config=model_config, budget=budget) @@ -580,6 +619,8 @@ def push( cache_dir: Path | None = None, model_config: Path | None = None, execution_budget: ExecutionBudget | None = None, + engine: Literal["inline", "off", "background"] = "inline", + background_deadline: float = 900.0, ) -> ReportDelivery: """Consume one Git transaction. Resolution, every comparison and reporting share a deadline.""" config = config or PushConfig() @@ -623,6 +664,7 @@ def push( cache_dir=cache_dir, execution_budget=budget, resolved=comparison, + engine=engine, ) reports.append(report) analyses.extend(report.result.analyses) @@ -674,6 +716,16 @@ def push( snapshots=tuple(s for r in reports for s in r.snapshots), change_contexts=tuple(r.change_context for r in reports if r.change_context is not None), quick=tuple(q for r in (*reports, *fallbacks) for q in r.quick), + background=_background( + repository, + [(r.change_context.base_revision, r.change_context.head_revision) for r in reports if r.change_context] + if engine == "background" + else [], + artifact, + background_deadline, + max_regions, + cache_dir, + ), ) from openultrasast.push.assistance import assist diff --git a/tests/test_push_background.py b/tests/test_push_background.py new file mode 100644 index 0000000..415b17d --- /dev/null +++ b/tests/test_push_background.py @@ -0,0 +1,82 @@ +"""Engine honesty within the deadline and the background engine (task 17.5, Requirement 9.5).""" + +from __future__ import annotations + +import json +import os +import time + +from test_push_runner import history + +from openultrasast.cpg.backend import NullBackend +from openultrasast.push import background +from openultrasast.push.runner import replay + + +def test_engine_off_runs_the_quick_tier_and_says_the_engine_was_skipped(tmp_path): + root, base, head, _ = history(tmp_path) + delivery = replay(root, base=base, head=head, artifact=tmp_path / "r.json", backend=NullBackend(), engine="off") + assert delivery.exit_code == 0 + assert "Skipped: engine checks were switched off for this run (--engine off)" in delivery.text + data = json.loads((tmp_path / "r.json").read_text()) + assert data["scans"] == [] and data["quick_tier"][0]["status"] == "completed" + + +def test_background_engine_result_is_shown_once_on_the_next_run(tmp_path): + root, base, head, _ = history(tmp_path) + artifacts = tmp_path / "artifacts" + first = replay(root, base=base, head=head, artifact=artifacts / "push-1.json", engine="background", background_deadline=60) + assert first.exit_code == 0 + assert "Engine: running in the background for " + head[:12] in first.text + assert "Skipped: engine checks run in the background after this push" in first.text + started = json.loads((artifacts / "push-1.json").read_text())["engine_background"] + assert started["status"] == "started" and started["head"] == head + result = background.results_dir(artifacts / "push-1.json", root) / f"{head}.json" + deadline = time.monotonic() + 90 + while not result.exists() and time.monotonic() < deadline: + time.sleep(0.2) + assert result.exists(), (background.results_dir(artifacts / "push-1.json", root) / f"{head}.log").read_text() + second = replay(root, base=base, head=head, artifact=artifacts / "push-2.json", backend=NullBackend(), engine="off") + assert f"Engine result for {head[:12]} from the previous push:" in second.text + assert str(result) in second.text + third = replay(root, base=base, head=head, artifact=artifacts / "push-3.json", backend=NullBackend(), engine="off") + assert "from the previous push" not in third.text + + +def test_one_background_engine_at_a_time(tmp_path): + root, base, head, _ = history(tmp_path) + artifact = tmp_path / "a" / "push.json" + directory = background.results_dir(artifact, root) + directory.mkdir(parents=True) + (directory / "running.json").write_text(json.dumps({"pid": os.getpid(), "head": "f" * 40})) + state = background.start(root, base=base, head=head, artifact=artifact, deadline_seconds=5) + assert state["status"] == "busy" and not (directory / f"{head}.log").exists() + delivery = replay(root, base=base, head=head, artifact=artifact, backend=NullBackend(), engine="background") + assert "is still busy, so none was started" in delivery.text + + +def test_previous_summarises_advisory_engine_findings(tmp_path): + root = tmp_path / "repo" + root.mkdir() + artifact = tmp_path / "out" / "push.json" + directory = background.results_dir(artifact, root) + directory.mkdir(parents=True) + delta = { + "family": "injection", + "novelty": "new", + "witness": "req.query.code -> eval", + "head_operation": {"path": "debug.js", "line": 2}, + } + payload = { + "result": {"coverage_status": "incomplete"}, + "admission": { + "defects": [], + "coverage_reasons": ["capability_unavailable"], + "dispositions": [{"admitted": False, "reasons": ["capability_unavailable"], "candidate": {"delta": delta}}], + }, + } + (directory / ("a" * 40 + ".json")).write_text(json.dumps(payload)) + (item,) = background.previous(artifact, root) + assert item["advisory"] == [{"family": "injection", "path": "debug.js", "line": 2, "novelty": "new"}] + assert item["finished"] and item["engine_ran"] and item["alerts"] == 0 + assert background.previous(artifact, root) == []