mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
feat(pre-push-safety-net): render bounded reports and preserve full evidence
This commit is contained in:
@@ -28,6 +28,7 @@ ROOTS = ("gate", "map_gate", "pair_gate", "cli", "__main__")
|
||||
|
||||
# Modules that stand alone: their own CLI subcommand or external protocol, deliberately off the scan path.
|
||||
SPEC_OWNED = {
|
||||
"push.report": "pre-push-safety-net Req 5.1/5.2/5.5/7.3: compact output and complete artifacts; runner integration pending",
|
||||
"push.policy": "pre-push-safety-net Req 2.4/3.1/3.4/4.1: targeted same-semantics base comparison; replay integration pending",
|
||||
"push.snapshot": "pre-push-safety-net Req 1.1/1.3/1.4/7.1: local immutable ref resolution; runner integration pending",
|
||||
"contracts": "pre-push-safety-net Req 7.3: strict shared manifest serialization, independent of push",
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Compact presentation and complete artifacts for an already-decided push."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import multiprocessing
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import asdict, dataclass
|
||||
from multiprocessing.connection import Connection
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
from typing import Literal
|
||||
|
||||
from openultrasast.model.contracts import ExecutionBudget
|
||||
from openultrasast.model.scan import ModelScanResult
|
||||
from openultrasast.push.contracts import PushComparison, PushResolution, PushResult
|
||||
from openultrasast.push.policy import AdmissionResult, _context_boundaries
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScanRecord:
|
||||
comparison: PushComparison
|
||||
side: Literal["base", "head"]
|
||||
scan: ModelScanResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushReport:
|
||||
result: PushResult
|
||||
admission: AdmissionResult
|
||||
scans: tuple[ScanRecord, ...]
|
||||
provenance: Mapping[str, str]
|
||||
timings: Mapping[str, float]
|
||||
resolution: PushResolution | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "provenance", MappingProxyType(dict(self.provenance)))
|
||||
object.__setattr__(self, "timings", MappingProxyType(dict(self.timings)))
|
||||
ids = tuple(defect.defect_id for defect in self.admission.defects)
|
||||
if ids != self.result.actionable_defect_ids:
|
||||
raise ValueError("report must retain the exact admitted defect set and order")
|
||||
if any(
|
||||
not all((d.witnesses, d.locations, d.consequences, d.repairs, d.change_evidence, d.comparisons)) for d in self.admission.defects
|
||||
):
|
||||
raise ValueError("actionable summaries require witnesses, locations, consequence, repair and change evidence")
|
||||
admitted = {d.candidate.delta.defect_id for d in self.admission.dispositions if d.admitted}
|
||||
if admitted != set(ids):
|
||||
raise ValueError("report must retain every admitted candidate disposition")
|
||||
comparisons = {analysis.comparison for analysis in self.result.analyses}
|
||||
if self.result.coverage_status == "complete_within_scope" and self.admission.coverage_reasons:
|
||||
raise ValueError("complete comparison cannot hide admission coverage gaps")
|
||||
if any(record.comparison not in comparisons or record.side not in ("base", "head") for record in self.scans):
|
||||
raise ValueError("scan evidence must belong to an exact reported comparison and side")
|
||||
for analysis in self.result.analyses:
|
||||
heads = [record.scan for record in self.scans if record.comparison == analysis.comparison and record.side == "head"]
|
||||
if any(scope not in [scan.scope for scan in heads] for scope in analysis.scopes):
|
||||
raise ValueError("reported scope requires the actual head scan evidence")
|
||||
completed = {outcome.identity for scan in heads for outcome in scan.question_outcomes if outcome.status == "completed"}
|
||||
if not set(analysis.completed_questions) <= completed:
|
||||
raise ValueError("completed questions require completed recorded outcomes")
|
||||
if analysis.coverage_status == "complete_within_scope":
|
||||
bases = [record.scan for record in self.scans if record.comparison == analysis.comparison and record.side == "base"]
|
||||
if not bases or any(not _completed_scan(scan) for scan in (*heads, *bases)):
|
||||
raise ValueError("complete comparison requires completed head and base evidence")
|
||||
required = {"repository", "engine", "facts", "queries", "policy"}
|
||||
if not required <= self.provenance.keys() or any(not isinstance(v, str) or not v.strip() for v in self.provenance.values()):
|
||||
raise ValueError("report requires repository, engine, facts, queries and policy provenance")
|
||||
if "total_seconds" not in self.timings or any(
|
||||
type(value) not in (int, float) or not math.isfinite(value) or value < 0 for value in self.timings.values()
|
||||
):
|
||||
raise ValueError("report requires finite nonnegative timings including total_seconds")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportDelivery:
|
||||
result: PushResult
|
||||
text: str
|
||||
artifact: Path | None
|
||||
error: str | None
|
||||
reporting_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
return int(self.result.push_disposition == "block")
|
||||
|
||||
|
||||
def _completed_scan(scan: ModelScanResult) -> bool:
|
||||
return bool(
|
||||
scan.scope
|
||||
and scan.scope.population_complete
|
||||
and not scan.scope.deferred
|
||||
and not _context_boundaries(scan)
|
||||
and not scan.degradations
|
||||
and {q.identity for q in scan.scope.selected} == {q.identity for q in scan.question_outcomes if q.status == "completed"}
|
||||
and all(q.status == "completed" for q in scan.question_outcomes)
|
||||
)
|
||||
|
||||
|
||||
def _write_artifact(report: PushReport, target: Path, temporary: Path, connection: Connection) -> None:
|
||||
"""Only this bounded child performs serialization or filesystem operations."""
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"result": report.result.to_payload(),
|
||||
"admission": report.admission.to_payload(),
|
||||
"scans": [asdict(record) for record in report.scans],
|
||||
"provenance": dict(report.provenance),
|
||||
"timings": dict(report.timings),
|
||||
"timing_scope": "Caller stage measurements; publication and rendering duration is returned in ReportDelivery.",
|
||||
"resolution": report.resolution.to_payload() if report.resolution else None,
|
||||
}
|
||||
json.dump(payload, stream, ensure_ascii=True, allow_nan=False, indent=2)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
connection.send(None)
|
||||
except Exception as error:
|
||||
connection.send("artifact_write_failed:" + type(error).__name__)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def _save(report: PushReport, target: Path, budget: ExecutionBudget) -> str | None:
|
||||
# Persist completed evidence during the remaining cancellation/report allowance.
|
||||
# Never restart that allowance for a late caller.
|
||||
end = budget.deadline_monotonic + budget.cancellation_allowance_seconds
|
||||
if time.monotonic() >= end:
|
||||
return "report_deadline_exhausted"
|
||||
reserve = min(0.1, budget.cancellation_allowance_seconds / 2)
|
||||
reader = writer = process = None
|
||||
try:
|
||||
temporary = target.with_name("." + target.name + ".partial-" + uuid.uuid4().hex)
|
||||
context = multiprocessing.get_context("fork")
|
||||
reader, writer = context.Pipe(duplex=False)
|
||||
process = context.Process(target=_write_artifact, args=(report, target, temporary, writer), daemon=True)
|
||||
process.start()
|
||||
writer.close()
|
||||
process.join(max(0.0, end - time.monotonic() - reserve))
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join(max(0.0, end - time.monotonic()))
|
||||
return "report_deadline_exhausted"
|
||||
if process.exitcode != 0 or not reader.poll():
|
||||
return "artifact_writer_failed"
|
||||
error = reader.recv()
|
||||
return None if error is None else error if isinstance(error, str) else "artifact_writer_failed"
|
||||
except (OSError, ValueError, EOFError, RuntimeError):
|
||||
return "artifact_writer_unavailable"
|
||||
finally:
|
||||
if writer is not None:
|
||||
writer.close()
|
||||
if reader is not None:
|
||||
reader.close()
|
||||
if process is not None:
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join(max(0.0, end - time.monotonic()))
|
||||
if not process.is_alive():
|
||||
process.close()
|
||||
|
||||
|
||||
def _short(value: str, limit: int = 180) -> str:
|
||||
# Bound before escaping: repository-controlled names/witnesses cannot add lines
|
||||
# or terminal escapes. Full spelling and every witness remain in the artifact.
|
||||
clipped = value if len(value) <= limit else value[: limit // 2] + "..." + value[-limit // 2 :]
|
||||
return json.dumps(clipped, ensure_ascii=True)[1:-1]
|
||||
|
||||
|
||||
def render_report(report: PushReport, *, artifact: Path | None, error: str | None = None) -> str:
|
||||
lines: list[str] = []
|
||||
defects = report.admission.defects
|
||||
for defect in defects[:3]:
|
||||
path, line = defect.locations[0]
|
||||
delta = next(
|
||||
disposition.candidate.delta
|
||||
for disposition in report.admission.dispositions
|
||||
if disposition.admitted and disposition.candidate.delta.defect_id == defect.defect_id
|
||||
)
|
||||
change = (
|
||||
"A protection present in the base is missing here."
|
||||
if delta.reason == "discharge_removed"
|
||||
else "This change connects input to the operation."
|
||||
)
|
||||
lines.extend(
|
||||
(
|
||||
f"- {_short(defect.family)} at {_short(path, 4096)}:{line}: {_short(defect.consequences[0])}",
|
||||
f" Evidence: {_short(defect.witnesses[0])}; Change: {change} ({_short(defect.change_evidence[0])})",
|
||||
f" Fix: {_short(defect.repairs[0])}",
|
||||
)
|
||||
)
|
||||
if len(defects) > 3:
|
||||
lines.append(f"{len(defects) - 3} more actionable defects retained in the detailed result.")
|
||||
if not defects:
|
||||
coverage = report.result.coverage_status
|
||||
if coverage == "complete_within_scope":
|
||||
completed = sum(len(analysis.completed_questions) for analysis in report.result.analyses)
|
||||
lines.append(f"No actionable defects in {completed} completed checks within the recorded scope.")
|
||||
elif coverage == "not_applicable":
|
||||
lines.append("No applicable code updates to check.")
|
||||
elif coverage == "unavailable":
|
||||
lines.append("No analysis result is available.")
|
||||
else:
|
||||
lines.append("No actionable defects reported; analysis is incomplete.")
|
||||
notices = []
|
||||
if report.result.coverage_status in ("incomplete", "unavailable"):
|
||||
reasons = report.admission.coverage_reasons
|
||||
if any("deadline" in reason for reason in reasons):
|
||||
notices.append("Analysis did not finish. Rerun with a longer analysis deadline.")
|
||||
elif any("capability" in reason for reason in reasons):
|
||||
notices.append("Some checks are not enabled for normal alerts. Review capability coverage in the details.")
|
||||
else:
|
||||
notices.append(
|
||||
"Some checks could not be completed. Review the comparison details and resolve the recorded gaps before retrying."
|
||||
)
|
||||
if 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))
|
||||
if artifact is not None:
|
||||
lines.append("Details: " + _short(str(artifact), 4096))
|
||||
if report.result.push_disposition == "block":
|
||||
lines.append("Push blocked by the configured policy.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def deliver_report(report: PushReport, artifact: Path, *, execution_budget: ExecutionBudget) -> ReportDelivery:
|
||||
started = time.monotonic()
|
||||
error = _save(report, artifact, execution_budget)
|
||||
saved = artifact if error is None else None
|
||||
text = render_report(report, artifact=saved, error=error)
|
||||
return ReportDelivery(report.result, text, saved, error, time.monotonic() - started)
|
||||
Reference in New Issue
Block a user