feat(pre-push-safety-net): replay immutable revisions through the full transaction

This commit is contained in:
Marius Ciepluch
2026-09-13 14:50:25 +02:00
parent 6b12b73411
commit 31e5ec7430
14 changed files with 4589 additions and 29 deletions
+29
View File
@@ -110,6 +110,17 @@ def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="ousast")
subparsers = parser.add_subparsers(dest="command", required=True)
replay_parser = subparsers.add_parser("pre-push", help="replay explicit local base/head revisions (experimental)")
replay_parser.add_argument("path", type=Path)
replay_parser.add_argument("--base", required=True)
replay_parser.add_argument("--head", required=True)
replay_parser.add_argument("--artifact", type=Path, required=True)
replay_parser.add_argument("--deadline", type=float, default=30.0)
replay_parser.add_argument("--cancellation-allowance", type=float, default=2.0)
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")
scan = subparsers.add_parser("scan", help="scan a local repository")
scan.add_argument("path", type=Path)
scan.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick")
@@ -189,6 +200,24 @@ def main(argv: list[str] | None = None) -> int:
candidates.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
if args.command == "pre-push":
from .config import PushConfig
from .push.runner import replay
try:
settings = PushConfig(
deadline_seconds=args.deadline,
cancellation_allowance_seconds=args.cancellation_allowance,
mode=args.mode,
incomplete_coverage_policy=args.incomplete_coverage,
)
delivery = replay(
args.path, base=args.base, head=args.head, artifact=args.artifact, config=settings, max_regions=args.max_regions
)
except ValueError as error:
parser.error(str(error))
print(delivery.text, end="")
return delivery.exit_code
if args.command == "scan":
return _scan(args.path, args.config, args.mode, args.fail_on)
if args.command == "index":
+21 -5
View File
@@ -195,9 +195,25 @@ def _valid_answer(scan: ModelScanResult, question: QuestionIdentity, operations:
return len(rows) == sum(op.question == question for op in operations)
def _context_boundaries(scan: ModelScanResult) -> tuple[str, ...]:
# A missing scheduling vector is not missing arbiter evidence after its question completed.
return tuple(b for b in scan.scope.unresolved_boundaries if not b.startswith("evidence_unknown:")) if scan.scope else ("scope_missing",)
def _context_boundaries(scan: ModelScanResult, question: QuestionIdentity | None = None) -> tuple[str, ...]:
# Aggregate coverage keeps every gap. A candidate may ignore only a recognized
# question-owned boundary attached to a different, recorded question. Unknown
# ownership and graph/declaration gaps remain global.
if scan.scope is None:
return ("scope_missing",)
owned_prefixes = (
"context_projection_unavailable:contributor-scan:",
"context_scope_empty:contributor-scan:",
"dynamic_external_or_depth_context_unresolved:contributor-scan:",
)
others = {q.identity.question_id for q in scan.scope.selected if q.identity != question}
others.update(q.identity.question_id for q in scan.scope.deferred if q.identity != question)
return tuple(
boundary
for boundary in scan.scope.unresolved_boundaries
if not boundary.startswith("evidence_unknown:")
and not (question is not None and boundary.startswith(owned_prefixes) and boundary.rsplit(":", 1)[-1] in others)
)
def _counterpart(question: QuestionIdentity, context: ChangeContext) -> QuestionIdentity:
@@ -268,7 +284,7 @@ def compare_evidence(
base
and base.scope
and base.scope.population_complete
and not base.scope.deferred
and all(question.reason == "tier_zero" for question in base.scope.deferred)
and not _context_boundaries(base)
and not base.degradations
and _valid_answer(base, counterpart, base_ops)
@@ -285,7 +301,7 @@ def compare_evidence(
reason = "change_context_unresolved"
elif (
not head.scope
or _context_boundaries(head)
or _context_boundaries(head, op.question)
or head.degradations
or not _valid_answer(head, op.question, head_ops)
or op.question not in {q.identity for q in head.scope.selected}
+9 -3
View File
@@ -15,9 +15,9 @@ from pathlib import Path
from types import MappingProxyType
from typing import Literal
from openultrasast.model.contracts import ExecutionBudget
from openultrasast.model.contracts import ChangeContext, ExecutionBudget
from openultrasast.model.scan import ModelScanResult
from openultrasast.push.contracts import PushComparison, PushResolution, PushResult
from openultrasast.push.contracts import PushComparison, PushResolution, PushResult, SnapshotManifest
from openultrasast.push.policy import AdmissionResult, _context_boundaries
@@ -36,6 +36,8 @@ class PushReport:
provenance: Mapping[str, str]
timings: Mapping[str, float]
resolution: PushResolution | None = None
snapshots: tuple[SnapshotManifest, ...] = ()
change_context: ChangeContext | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance)))
@@ -109,6 +111,8 @@ def _write_artifact(report: PushReport, target: Path, temporary: Path, connectio
with os.fdopen(fd, "w", encoding="utf-8") as stream:
payload = {
"schema_version": 1,
"snapshots": [item.to_payload() for item in report.snapshots],
"change_context": report.change_context.to_payload() if report.change_context else None,
"result": report.result.to_payload(),
"admission": report.admission.to_payload(),
"scans": [asdict(record) for record in report.scans],
@@ -223,7 +227,9 @@ def render_report(report: PushReport, *, artifact: Path | None, error: str | Non
notices.append(
"Some checks could not be completed. Review the comparison details and resolve the recorded gaps before retrying."
)
if error:
if error == "artifact_inside_repository":
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.")
if notices:
lines.append("Notice: " + " ".join(notices))
+279
View File
@@ -0,0 +1,279 @@
"""Bounded, local base/head replay through the existing repository driver."""
from __future__ import annotations
import hashlib
import multiprocessing
import os
import pickle
import selectors
import time
from collections.abc import Callable
from dataclasses import asdict, replace
from pathlib import Path
from typing import Any, Literal, TypeVar, cast
from openultrasast.config import PushConfig
from openultrasast.cpg.backend import joern_version, resolve_cpg_backend
from openultrasast.mapping import analyze_entry_points
from openultrasast.model.contracts import ExecutionBudget
from openultrasast.model.regions import ScanRegion, regions_for
from openultrasast.model.scan import ScanBudget, scan_repository
from openultrasast.model.shipped import declared_sources
from openultrasast.preprocess import build_file_target, enumerate_source_files
from openultrasast.push.contracts import ComparisonAnalysis, SnapshotManifest
from openultrasast.push.policy import (
AdmissionCandidate,
AdmissionResult,
CapabilityKey,
admission_push_result,
admit_candidates,
compare_targeted_base,
semantics_digest,
)
from openultrasast.push.report import PushReport, ReportDelivery, ScanRecord, _completed_scan, deliver_report, render_report
from openultrasast.push.snapshot import SnapshotAdapter
_T = TypeVar("_T")
def _prepare(function: Callable[[], _T], budget: ExecutionBudget) -> _T:
"""Bound pure local discovery/metadata work, including serialization, on Linux.
Read incrementally while the child writes; no pipe-buffer deadlock or unbounded
blocking recv. The child never launches engines or project code.
"""
if time.monotonic() >= budget.deadline_monotonic:
raise TimeoutError("deadline_exhausted")
reader, writer = os.pipe()
def child() -> None:
os.close(reader)
try:
payload: tuple[bool, Any]
try:
payload = (True, function())
except Exception as error:
payload = (False, type(error).__name__)
with os.fdopen(writer, "wb") as stream:
pickle.dump(payload, stream, protocol=5)
finally:
os._exit(0)
process = multiprocessing.get_context("fork").Process(target=child)
data = bytearray()
try:
process.start()
os.close(writer)
writer = -1
with selectors.DefaultSelector() as selector:
selector.register(reader, selectors.EVENT_READ)
while selector.get_map():
remaining = budget.deadline_monotonic - time.monotonic()
if remaining <= 0:
raise TimeoutError("deadline_exhausted")
for key, _ in selector.select(min(0.05, remaining)):
chunk = os.read(key.fd, 65536)
if not chunk:
selector.unregister(key.fileobj)
else:
data.extend(chunk)
if len(data) > 32 * 1024**2:
raise ValueError("preparation_output_limit")
process.join(max(0, budget.deadline_monotonic - time.monotonic()))
if process.is_alive() or process.exitcode != 0 or not data:
raise RuntimeError("preparation_worker_failed")
success, result = pickle.loads(data) # trusted child, never repository-provided pickle
if not success:
raise RuntimeError("preparation_failed:" + result)
return cast(_T, result)
finally:
os.close(reader)
if writer >= 0:
os.close(writer)
if process.pid is not None:
if process.is_alive():
process.kill()
process.join(max(0, budget.deadline_monotonic + budget.cancellation_allowance_seconds - time.monotonic()))
if not process.is_alive():
process.close()
def _discover(root: Path) -> tuple[tuple[ScanRegion, ...], tuple[bytes, ...]]:
# Same proposer and declaration-based shipping rules as ordinary scans. Do not
# call preprocess's live-HEAD probe on a materialized, intentionally Git-free tree.
files = enumerate_source_files(root)
targets = [build_file_target(root, path) for path in files]
regions = regions_for(analyze_entry_points(root, targets), targets, shipped=declared_sources(root))
# Conservatively seed every tracked declaration/non-source file. This is change
# context only, never a graph allowlist or a second scope selector.
sources = set(files)
declarations = tuple(os.fsencode(str(p.relative_to(root))) for p in root.rglob("*") if p.is_file() and p not in sources)
return regions, declarations
def _provenance(repository: Path, scan_budget: ScanBudget) -> dict[str, str]:
root = Path(__file__).resolve().parents[1]
def digest(paths: list[Path]) -> str:
hasher = hashlib.sha256()
for path in sorted(paths):
hasher.update(str(path.relative_to(root)).encode())
hasher.update(path.read_bytes())
return hasher.hexdigest()
version = joern_version()
engine = "joern-" + (".".join(map(str, version)) if version else "unavailable_or_unidentified")
return {
"repository": str(repository.absolute()),
"engine": engine,
"facts": digest(list((root / "ruleset").rglob("*.toml"))),
"queries": digest(list((root / "cpg/queries").glob("*.sc"))),
"policy": digest(list((root / "push").glob("*.py"))),
"core": digest(list((root / "model").glob("*.py")) + list((root / "cpg").glob("*.py"))),
"semantics": semantics_digest(engine_identity=engine, options=asdict(scan_budget)),
"ranking_mode": "evidence",
"model": "disabled",
"capabilities": "empty_registry_pending_task_8.3",
"cache": "cold_no_reuse",
}
def replay(
repository: Path,
*,
base: str,
head: str,
artifact: Path,
config: PushConfig | None = None,
backend: Any = None,
max_regions: int = 500,
) -> ReportDelivery:
"""Run a single explicit comparison; exit policy never stands for coverage."""
config = config or PushConfig()
if max_regions < 1:
raise ValueError("max_regions must be positive")
started = time.monotonic()
budget = ExecutionBudget(started + config.deadline_seconds, config.cancellation_allowance_seconds)
scan_budget = ScanBudget(max_model_calls=0, max_regions=max_regions, order_by_evidence=True)
records: list[ScanRecord] = []
manifests: list[SnapshotManifest] = []
reasons: list[str] = []
admission = AdmissionResult((), (), ())
comparison = None
context = None
timings: dict[str, float] = {
"deadline_seconds": config.deadline_seconds,
"cancellation_allowance_seconds": config.cancellation_allowance_seconds,
}
provenance = {key: "unavailable_preparation_incomplete" for key in ("engine", "facts", "queries", "policy")}
provenance.update(repository=str(repository.absolute()), model="disabled", requested_base=base, requested_head=head)
stage = "resolution"
stage_started = started
try:
adapter = SnapshotAdapter(repository, execution_budget=budget)
comparison = adapter.resolve_replay(base, head)
timings[stage + "_seconds"] = time.monotonic() - stage_started
stage, stage_started = "provenance", time.monotonic()
provenance.update(_prepare(lambda: _provenance(repository, scan_budget), budget))
timings[stage + "_seconds"] = time.monotonic() - stage_started
stage, stage_started = "preparation", time.monotonic()
with adapter.materialize(comparison.head_oid, budget=budget) as tip:
manifests.append(tip.manifest)
assert comparison.base_oid is not None
with adapter.materialize(comparison.base_oid, budget=budget) as old:
manifests.append(old.manifest)
for manifest in manifests:
reasons.extend("snapshot:" + boundary.reason for boundary in manifest.boundaries)
head_regions, head_declarations = _prepare(lambda: _discover(tip.root), budget)
base_regions, base_declarations = _prepare(lambda: _discover(old.root), budget)
context = adapter.compare(comparison, declaration_paths=tuple(set(head_declarations + base_declarations)), budget=budget)
context = replace(context, unresolved_boundaries=tuple(dict.fromkeys((*context.unresolved_boundaries, *reasons))))
reasons.extend(context.unresolved_boundaries)
timings[stage + "_seconds"] = time.monotonic() - stage_started
stage, stage_started = "head", time.monotonic()
head_scan = scan_repository(
tip.root,
head_regions,
backend=backend or resolve_cpg_backend(),
budget=scan_budget,
execution_budget=budget,
ranking_mode="evidence",
unit="repository",
population_complete=all(m.complete for m in manifests),
change_context=context,
)
records.append(ScanRecord(comparison, "head", head_scan))
timings[stage + "_seconds"] = time.monotonic() - stage_started
stage, stage_started = "base", time.monotonic()
delta = compare_targeted_base(
old.root,
head=head_scan,
head_regions=head_regions,
base_regions=base_regions,
context=context,
backend=backend or resolve_cpg_backend(),
execution_budget=budget,
scan_budget=scan_budget,
head_semantics=provenance["semantics"],
base_semantics=provenance["semantics"],
)
if delta.base_scan is not None:
records.append(ScanRecord(comparison, "base", delta.base_scan))
reasons.extend(delta.coverage_reasons)
timings[stage + "_seconds"] = time.monotonic() - stage_started
stage, stage_started = "admission", time.monotonic()
candidates = tuple(
AdmissionCandidate(
c,
CapabilityKey(
c.head_operation.question.language if c.head_operation else "unknown",
"unspecified",
"unspecified",
c.family,
c.head_operation.mechanism if c.head_operation else "taint",
"unreviewed",
delta.semantics,
),
comparison,
tuple(reasons),
)
for c in delta.candidates
)
admission = _prepare(lambda: admit_candidates(candidates), budget)
timings[stage + "_seconds"] = time.monotonic() - stage_started
stage, stage_started = "cleanup", time.monotonic()
timings[stage + "_seconds"] = time.monotonic() - stage_started
except Exception as error:
reasons.append(stage + "_failed:" + type(error).__name__)
timings[stage + "_seconds"] = time.monotonic() - stage_started
if time.monotonic() >= budget.deadline_monotonic:
reasons.append("deadline_exhausted")
head_records = [r.scan for r in records if r.side == "head"]
for record in records:
if record.scan.scope is not None:
reasons.extend(record.scan.scope.unresolved_boundaries)
if not records or not all(_completed_scan(r.scan) for r in records):
reasons.append("analysis_incomplete")
if not any(r.side == "base" for r in records):
reasons.append("base_comparison_unavailable")
admission = replace(admission, coverage_reasons=tuple(dict.fromkeys((*admission.coverage_reasons, *reasons))))
analyses: tuple[ComparisonAnalysis, ...] = ()
if comparison is not None:
scopes = tuple(scan.scope for scan in head_records if scan.scope is not None)
completed = tuple(outcome.identity for scan in head_records for outcome in scan.question_outcomes if outcome.status == "completed")
coverage: Literal["unavailable", "incomplete", "complete_within_scope"] = (
"unavailable" if not records else "incomplete" if admission.coverage_reasons else "complete_within_scope"
)
analyses = (ComparisonAnalysis(comparison, scopes, completed, coverage),)
result = admission_push_result(admission, analyses, config=config)
timings["total_seconds"] = time.monotonic() - started
report = PushReport(result, admission, tuple(records), provenance, timings, snapshots=tuple(manifests), change_context=context)
try:
artifact.resolve().relative_to(repository.resolve())
except ValueError:
pass
else:
write_error = "artifact_inside_repository"
return ReportDelivery(result, render_report(report, artifact=None, error=write_error), None, write_error)
return deliver_report(report, artifact, execution_budget=budget)
+26 -2
View File
@@ -44,7 +44,8 @@ class SnapshotInputError(ValueError):
class SnapshotAdapter:
def __init__(self, repository: Path, *, comparison_base: str | None = None) -> None:
def __init__(self, repository: Path, *, comparison_base: str | None = None, execution_budget: ExecutionBudget | None = None) -> None:
self.execution_budget = execution_budget
self.repository = repository.resolve()
self.comparison_base = comparison_base
# Hook-inherited Git variables may redirect objects or worktrees elsewhere.
@@ -72,10 +73,22 @@ class SnapshotAdapter:
def _repository_path(self, *args: str) -> Path:
# Binary framing also matters for the repository's own whitespace/non-UTF8 name.
raw = self._bounded_git(("rev-parse", *args), 65536, ExecutionBudget(time.monotonic() + 10.0, 2.0), None, [])
raw = self._bounded_git(
("rev-parse", *args), 65536, self.execution_budget or ExecutionBudget(time.monotonic() + 10.0, 2.0), None, []
)
return Path(os.fsdecode(raw.removesuffix(b"\n"))).resolve()
def _git(self, *args: str, input: str | None = None) -> subprocess.CompletedProcess[str]:
if self.execution_budget is not None:
if input not in (None, ""):
raise SnapshotInputError("bounded_resolution_requires_empty_stdin")
try:
raw = self._bounded_git(tuple(args), 16 * 1024**2, self.execution_budget, None, [])
return subprocess.CompletedProcess(args, 0, raw.decode("utf-8", "surrogateescape"), "")
except SnapshotInputError as error:
if str(error).startswith("local_git_") and "_failed_exit_" in str(error):
return subprocess.CompletedProcess(args, 1, "", "")
raise
try:
return subprocess.run(
["git", "-c", "protocol.allow=never", "-c", "core.hooksPath=" + os.devnull, "-C", str(self.repository), *args],
@@ -97,6 +110,17 @@ class SnapshotAdapter:
raise SnapshotInputError(f"local Git {args[0]} failed (exit {result.returncode})")
return result.stdout.strip()
def resolve_replay(self, base: str, head: str) -> PushComparison:
"""Resolve explicitly supplied local revisions without inferred baselines or fetching."""
resolved = []
for revision in (base, head):
oid = self._required("rev-parse", "--verify", "--end-of-options", revision)
commit, kind = self._commit(oid)
if commit is None:
raise SnapshotInputError("replay_revision_" + kind)
resolved.append(commit)
return PushComparison(resolved[1], resolved[0], "explicit_local_replay", ("refs/heads/replay",))
def parse_updates(self, data: str) -> tuple[PushUpdate, ...]:
"""Validate every record before attempting resolution; malformed input fails closed."""
updates = []