mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(pre-push-safety-net): establish shared push and engine contracts
This commit is contained in:
@@ -5,6 +5,9 @@ import os
|
||||
import tomllib
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from openultrasast.contracts import Contract
|
||||
|
||||
DEFAULT_VECTOR_STORE = "json-local"
|
||||
|
||||
@@ -163,6 +166,28 @@ class ObligationsConfig:
|
||||
policy_path: str = ".openultrasast/obligations.toml"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushConfig(Contract):
|
||||
"""Inert experimental push settings; configuring them does not install a hook.
|
||||
|
||||
The 2 GiB local cache cap is a storage budget, not a performance promise. The
|
||||
eventual cache evicts unleased entries; deadline and cleanup are separate costs.
|
||||
No endpoint, fetch, engine download or capability admission is enabled here.
|
||||
"""
|
||||
|
||||
comparison_base: str | None = None
|
||||
deadline_seconds: float = 30.0
|
||||
cancellation_allowance_seconds: float = 2.0
|
||||
cache_max_bytes: int = 2 * 1024 ** 3
|
||||
mode: Literal["advisory", "blocking"] = "advisory"
|
||||
incomplete_coverage_policy: Literal["allow", "block"] = "allow"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if self.deadline_seconds <= 0 or self.cancellation_allowance_seconds <= 0 or self.cache_max_bytes <= 0:
|
||||
raise ValueError("push deadlines, cancellation allowance and cache limit must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedConfig:
|
||||
models: ModelConfig = ModelConfig()
|
||||
@@ -181,6 +206,7 @@ class ResolvedConfig:
|
||||
variants: VariantsConfig = VariantsConfig()
|
||||
obligations: ObligationsConfig = ObligationsConfig()
|
||||
model: ModelLayerConfig = ModelLayerConfig()
|
||||
push: PushConfig = PushConfig()
|
||||
runs_dir: str = ".openultrasast/runs"
|
||||
|
||||
|
||||
@@ -208,13 +234,19 @@ def load_config(config_path: Path | None = None) -> ResolvedConfig:
|
||||
variants=_load_variants(data.get("variants", {})),
|
||||
obligations=_load_obligations(data.get("obligations", {})),
|
||||
model=_load_model(data.get("model", {})),
|
||||
push=PushConfig.from_payload(data.get("push", {})),
|
||||
runs_dir=os.environ.get("OPENULTRASAST_RUNS_DIR", ".openultrasast/runs"),
|
||||
)
|
||||
|
||||
|
||||
def config_payload(config: ResolvedConfig) -> dict[str, object]:
|
||||
"""Reproducible resolved settings, including the separate experimental push policy."""
|
||||
return asdict(config)
|
||||
|
||||
|
||||
def write_resolved_config(config: ResolvedConfig, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(asdict(config), indent=2, sort_keys=True) + "\n")
|
||||
path.write_text(json.dumps(config_payload(config), indent=2, sort_keys=True) + "\n")
|
||||
|
||||
|
||||
def _section(value: object) -> dict[str, object]:
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Strict immutable manifest values shared by engine and adapters (pre-push Req 7.3).
|
||||
|
||||
These schemas validate structure, not source readability, Git object existence or alert
|
||||
eligibility. Live resources deliberately have no manifest decoder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import fields
|
||||
from types import UnionType
|
||||
from typing import Any, Literal, Self, get_args, get_origin, get_type_hints
|
||||
|
||||
_HINTS: dict[type, dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _hints(cls: type) -> dict[str, Any]:
|
||||
if cls not in _HINTS:
|
||||
_HINTS[cls] = get_type_hints(cls)
|
||||
return _HINTS[cls]
|
||||
|
||||
|
||||
def _value(value: object, kind: Any, *, decode: bool) -> object:
|
||||
origin, args = get_origin(kind), get_args(kind)
|
||||
if origin is UnionType:
|
||||
for candidate in args:
|
||||
try:
|
||||
return _value(value, candidate, decode=decode)
|
||||
except ValueError:
|
||||
pass
|
||||
raise ValueError(f"invalid union value: {value!r}")
|
||||
if origin is Literal:
|
||||
if any(type(value) is type(option) and value == option for option in args):
|
||||
return value
|
||||
elif origin is tuple:
|
||||
if type(value) is tuple or (decode and type(value) is list):
|
||||
items = tuple(value) # type: ignore[arg-type]
|
||||
if len(args) == 2 and args[1] is Ellipsis:
|
||||
return tuple(_value(item, args[0], decode=decode) for item in items)
|
||||
if len(items) == len(args):
|
||||
return tuple(_value(item, item_type, decode=decode) for item, item_type in zip(items, args, strict=True))
|
||||
elif isinstance(kind, type) and issubclass(kind, Contract):
|
||||
if decode:
|
||||
return kind.from_payload(value)
|
||||
if isinstance(value, kind):
|
||||
return value
|
||||
elif kind is float:
|
||||
if type(value) in (int, float):
|
||||
try:
|
||||
if math.isfinite(value): # type: ignore[arg-type]
|
||||
return value
|
||||
except OverflowError:
|
||||
pass
|
||||
elif kind is str:
|
||||
if type(value) is str and value.strip():
|
||||
return value
|
||||
elif type(value) is kind:
|
||||
return value
|
||||
raise ValueError(f"invalid {kind}: {value!r}")
|
||||
|
||||
|
||||
def _payload(value: object) -> object:
|
||||
if isinstance(value, Contract):
|
||||
return value.to_payload()
|
||||
if isinstance(value, tuple):
|
||||
return [_payload(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
class Contract:
|
||||
"""Base for frozen dataclasses with strict construction and JSON round trips."""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name, kind in _hints(type(self)).items():
|
||||
try:
|
||||
_value(getattr(self, name), kind, decode=False)
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{type(self).__name__}.{name}: {error}") from error
|
||||
|
||||
def to_payload(self) -> dict[str, object]:
|
||||
return {item.name: _payload(getattr(self, item.name)) for item in fields(self)} # type: ignore[arg-type]
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: object) -> Self:
|
||||
if not isinstance(payload, dict) or not all(isinstance(key, str) for key in payload):
|
||||
raise ValueError(f"{cls.__name__} requires an object")
|
||||
hints = _hints(cls)
|
||||
if unknown := payload.keys() - hints.keys():
|
||||
raise ValueError(f"unknown {cls.__name__} fields: {sorted(unknown)}")
|
||||
try:
|
||||
return cls(**{name: _value(value, hints[name], decode=True) for name, value in payload.items()})
|
||||
except TypeError as error:
|
||||
raise ValueError(f"invalid {cls.__name__}: {error}") from error
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Backend-owned graph provenance and live resource boundary (pre-push Req 4.3, 7.3).
|
||||
|
||||
Structural validity is necessary but insufficient for reuse. Task 6.1 will validate
|
||||
actual source bytes, graph digest/census and engine compatibility before issuing leases.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from openultrasast.contracts import Contract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphIdentity(Contract):
|
||||
source_digest: str
|
||||
declarations_digest: str
|
||||
exclusions_digest: str
|
||||
unit: str
|
||||
language: str
|
||||
engine_version: str
|
||||
frontend: str
|
||||
frontend_version: str
|
||||
overlay_semantics: tuple[str, ...]
|
||||
overlay_options: tuple[tuple[str, str], ...]
|
||||
frontend_options: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
for options in (self.overlay_options, self.frontend_options):
|
||||
if len({key for key, _ in options}) != len(options):
|
||||
raise ValueError("graph option names must be unique")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphCensus(Contract):
|
||||
files: int
|
||||
methods: int
|
||||
calls: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if min(self.files, self.methods, self.calls) < 0:
|
||||
raise ValueError("graph census counts must be nonnegative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GraphArtifact(Contract):
|
||||
identity: GraphIdentity
|
||||
graph_digest: str
|
||||
source_bytes: int
|
||||
census: GraphCensus
|
||||
completeness: Literal["complete", "partial"]
|
||||
diagnostics: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if self.source_bytes < 0:
|
||||
raise ValueError("source byte count must be nonnegative")
|
||||
if self.completeness == "complete" and (not self.source_bytes or not self.census.files or self.diagnostics):
|
||||
raise ValueError("complete graph requires readable source, a file census and no incomplete diagnostics")
|
||||
if self.completeness == "partial" and not self.diagnostics:
|
||||
raise ValueError("partial graph requires a recorded reason")
|
||||
|
||||
|
||||
class GraphLease(Protocol):
|
||||
"""A backend-issued live lease; never reconstructed from a JSON manifest.
|
||||
|
||||
The backend validates the artifact before issuing the lease and isolates loading
|
||||
when it may mutate graph bytes. The consumer owns releasing it exactly once; the
|
||||
backend's release is idempotent and bounded by the supplied monotonic deadline.
|
||||
A cached artifact survives release. Implementations belong to task 6.1.
|
||||
"""
|
||||
|
||||
@property
|
||||
def artifact(self) -> GraphArtifact: ...
|
||||
|
||||
@property
|
||||
def path(self) -> Path: ...
|
||||
|
||||
def close(self, *, deadline_monotonic: float) -> None: ...
|
||||
@@ -28,6 +28,11 @@ 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 = {
|
||||
"contracts": "pre-push-safety-net Req 7.3: strict shared manifest serialization, independent of push",
|
||||
"model.contracts": "pre-push-safety-net Req 4.1/7.3: generic deadline, change and exact scope contracts; integration pending",
|
||||
"cpg.artifact": "pre-push-safety-net Req 4.3/7.3: backend graph identity and live lease contract; validation integration pending",
|
||||
"push": "pre-push-safety-net Req 5.5/7.3: experimental push contract package; no hook installed",
|
||||
"push.contracts": "pre-push-safety-net Req 5.5/7.3: immutable comparisons and independent result statuses; runner pending",
|
||||
"model.specs": "model-grounded-detection Req 7.2-7.4: the security vocabularies the CPG queries are parameterised by",
|
||||
"model.endpoint": "model-grounded-detection Req 8: the judge client, wired by model/judge.py in group 3",
|
||||
"model.audit": "model-grounded-detection Req 3: this classifier; run by the maintainer, not by the scan",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Language-agnostic change evidence and exact ranker scope (pre-push Req 7.3).
|
||||
|
||||
These are integration contracts; constructing them neither selects nor executes work.
|
||||
Revision identities are opaque to the engine. The snapshot adapter resolves Git objects.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from openultrasast.contracts import Contract
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionBudget(Contract):
|
||||
"""One absolute monotonic deadline, with a distinct bounded cleanup allowance.
|
||||
|
||||
Process-local: do not carry the absolute clock value across process restarts.
|
||||
Manifests should record elapsed stage costs and configured durations instead.
|
||||
"""
|
||||
|
||||
deadline_monotonic: float
|
||||
cancellation_allowance_seconds: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if self.deadline_monotonic < 0 or self.cancellation_allowance_seconds <= 0:
|
||||
raise ValueError("deadline must be nonnegative and cancellation allowance positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestionIdentity(Contract):
|
||||
unit: str
|
||||
language: str
|
||||
path: str
|
||||
function: str | None
|
||||
family: str
|
||||
|
||||
@property
|
||||
def question_id(self) -> str:
|
||||
encoded = json.dumps(self.to_payload(), sort_keys=True, separators=(",", ":")).encode()
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChangedSpan(Contract):
|
||||
path: str
|
||||
start_line: int
|
||||
end_line: int
|
||||
side: Literal["base", "head"]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if self.start_line < 1 or self.end_line < self.start_line:
|
||||
raise ValueError("changed spans require positive inclusive ordered lines")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathRename(Contract):
|
||||
base_path: str
|
||||
head_path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectedRelationship(Contract):
|
||||
source: QuestionIdentity
|
||||
target: QuestionIdentity
|
||||
kind: str
|
||||
evidence: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChangeContext(Contract):
|
||||
base_revision: str | None
|
||||
head_revision: str
|
||||
changed_paths: tuple[str, ...]
|
||||
deleted_paths: tuple[str, ...]
|
||||
spans: tuple[ChangedSpan, ...]
|
||||
renames: tuple[PathRename, ...]
|
||||
declaration_paths: tuple[str, ...]
|
||||
relationships: tuple[AffectedRelationship, ...]
|
||||
unresolved_boundaries: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RankedQuestion(Contract):
|
||||
identity: QuestionIdentity
|
||||
priority: float
|
||||
evidence: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeferredQuestion(Contract):
|
||||
identity: QuestionIdentity
|
||||
reason: str
|
||||
evidence: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScopeDecision(Contract):
|
||||
ranker_version: str
|
||||
ranking_mode: str
|
||||
# Explicit census assertion: an empty list by itself is never proof of no work.
|
||||
population_complete: bool
|
||||
selected: tuple[RankedQuestion, ...]
|
||||
deferred: tuple[DeferredQuestion, ...]
|
||||
unresolved_boundaries: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
identities = [item.identity for item in self.selected] + [item.identity for item in self.deferred]
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("question identities must be unique across selected and deferred scope")
|
||||
@@ -0,0 +1 @@
|
||||
"""Experimental pre-push contracts; no hook or admitted capability is enabled here."""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Push-owned immutable identities and independent result statuses (Req 5.5, 7.3).
|
||||
|
||||
Git resolution, admission policy and execution are separate later implementations.
|
||||
No default result status can turn an unexecuted contract into a complete negative.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from openultrasast.contracts import Contract
|
||||
from openultrasast.model.contracts import QuestionIdentity, ScopeDecision
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushUpdate(Contract):
|
||||
local_ref: str
|
||||
local_oid: str
|
||||
remote_ref: str
|
||||
remote_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushComparison(Contract):
|
||||
head_oid: str
|
||||
base_oid: str | None
|
||||
base_reason: str
|
||||
refs: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if not self.refs or len(set(self.refs)) != len(self.refs):
|
||||
raise ValueError("comparison must retain distinct associated refs")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComparisonAnalysis(Contract):
|
||||
"""Scope and completion belong to this exact base/head comparison.
|
||||
|
||||
A question completed for one comparison never completes it for another base,
|
||||
even when the target graph is shared. This record does not assert admission.
|
||||
"""
|
||||
|
||||
comparison: PushComparison
|
||||
scopes: tuple[ScopeDecision, ...]
|
||||
completed_questions: tuple[QuestionIdentity, ...]
|
||||
coverage_status: Literal["complete_within_scope", "incomplete", "unavailable"]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
selected_list = [item.identity for scope in self.scopes for item in scope.selected]
|
||||
selected = set(selected_list)
|
||||
completed = set(self.completed_questions)
|
||||
if len(selected) != len(selected_list):
|
||||
raise ValueError("a comparison cannot select the same question across multiple scopes")
|
||||
if len(completed) != len(self.completed_questions) or not completed <= selected:
|
||||
raise ValueError("completed questions must be unique members of this comparison's selected scope")
|
||||
if self.coverage_status == "complete_within_scope":
|
||||
if self.comparison.base_oid is None or not self.scopes:
|
||||
raise ValueError("complete coverage requires compared revisions and an explicit scope census")
|
||||
if completed != selected or any(
|
||||
not scope.population_complete or scope.deferred or scope.unresolved_boundaries for scope in self.scopes
|
||||
):
|
||||
raise ValueError("deferred, unexecuted or unresolved work cannot be complete coverage")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PushResult(Contract):
|
||||
analyses: tuple[ComparisonAnalysis, ...]
|
||||
finding_status: Literal["actionable", "none"]
|
||||
coverage_status: Literal["complete_within_scope", "incomplete", "unavailable", "not_applicable"]
|
||||
push_disposition: Literal["allow", "block"]
|
||||
actionable_defect_ids: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if (self.finding_status == "actionable") != bool(self.actionable_defect_ids):
|
||||
raise ValueError("finding status must match admitted defect identities")
|
||||
if len(set(self.actionable_defect_ids)) != len(self.actionable_defect_ids):
|
||||
raise ValueError("admitted defect identities must be unique")
|
||||
if self.coverage_status == "complete_within_scope" and (
|
||||
not self.analyses or any(item.coverage_status != "complete_within_scope" for item in self.analyses)
|
||||
):
|
||||
raise ValueError("aggregate complete coverage requires completed comparison analyses")
|
||||
if self.coverage_status == "not_applicable" and (self.analyses or self.actionable_defect_ids):
|
||||
raise ValueError("not applicable cannot carry analyzed scope or findings")
|
||||
Reference in New Issue
Block a user