2026-09-12 19:21:31 +02:00
|
|
|
"""Approved task 1.4 contracts: persist identities without inventing completed analysis."""
|
2026-09-12 21:12:45 +02:00
|
|
|
|
2026-09-12 19:21:31 +02:00
|
|
|
import ast
|
|
|
|
|
import json
|
|
|
|
|
from dataclasses import FrozenInstanceError, replace
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from openultrasast.config import PushConfig, config_payload, load_config
|
|
|
|
|
from openultrasast.contracts import Contract
|
|
|
|
|
from openultrasast.cpg.artifact import GraphArtifact, GraphCensus, GraphIdentity, GraphLease
|
|
|
|
|
from openultrasast.model.contracts import (
|
|
|
|
|
ChangeContext,
|
|
|
|
|
ChangedSpan,
|
|
|
|
|
DeferredQuestion,
|
|
|
|
|
QuestionIdentity,
|
|
|
|
|
RankedQuestion,
|
|
|
|
|
ScopeDecision,
|
|
|
|
|
)
|
|
|
|
|
from openultrasast.push.contracts import ComparisonAnalysis, PushComparison, PushResult, PushUpdate
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def question(language: str = "javascript", path: str = "src/api.js") -> QuestionIdentity:
|
|
|
|
|
return QuestionIdentity("api", language, path, "handler", "injection")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def scope() -> ScopeDecision:
|
|
|
|
|
return ScopeDecision("core-v1", "evidence", True, (RankedQuestion(question(), 2.5, ("source→operation",)),), (), ())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def comparison() -> PushComparison:
|
|
|
|
|
return PushComparison("a" * 64, "b" * 64, "supplied_remote_tip", ("refs/heads/main",))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_round_trip_and_ordinary_scan_compatibility(tmp_path: Path) -> None:
|
|
|
|
|
baseline = load_config()
|
|
|
|
|
path = tmp_path / "settings.toml"
|
2026-09-12 21:12:45 +02:00
|
|
|
path.write_text(
|
|
|
|
|
'[push]\ncomparison_base = "refs/heads/main"\ndeadline_seconds = 11.5\n'
|
|
|
|
|
'cancellation_allowance_seconds = 1\ncache_max_bytes = 4096\nmode = "blocking"\n'
|
|
|
|
|
'incomplete_coverage_policy = "block"\n'
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
configured = load_config(path)
|
|
|
|
|
assert configured.push == PushConfig("refs/heads/main", 11.5, 1, 4096, "blocking", "block")
|
|
|
|
|
assert PushConfig.from_payload(json.loads(json.dumps(config_payload(configured)["push"]))) == configured.push
|
|
|
|
|
before, after = config_payload(baseline), config_payload(configured)
|
|
|
|
|
before.pop("push")
|
|
|
|
|
after.pop("push")
|
|
|
|
|
assert before == after
|
|
|
|
|
assert baseline.push == PushConfig()
|
|
|
|
|
assert baseline.push.deadline_seconds == 30
|
|
|
|
|
assert baseline.push.cancellation_allowance_seconds == 2
|
2026-09-12 21:12:45 +02:00
|
|
|
assert baseline.push.cache_max_bytes == 2 * 1024**3
|
2026-09-12 19:21:31 +02:00
|
|
|
assert baseline.push.mode == "advisory"
|
|
|
|
|
assert baseline.push.incomplete_coverage_policy == "allow"
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 21:12:45 +02:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"setting",
|
|
|
|
|
[
|
|
|
|
|
'push = "bad"',
|
|
|
|
|
"push = []",
|
|
|
|
|
"[push]\ndeadline_seconds = true",
|
|
|
|
|
'[push]\ndeadline_seconds = "30"',
|
|
|
|
|
"[push]\ndeadline_seconds = nan",
|
|
|
|
|
"[push]\ndeadline_seconds = inf",
|
|
|
|
|
"[push]\ndeadline_seconds = 0",
|
|
|
|
|
"[push]\ncancellation_allowance_seconds = -1",
|
|
|
|
|
"[push]\ncache_max_bytes = 0",
|
|
|
|
|
"[push]\ncache_max_bytes = 1.5",
|
|
|
|
|
"[push]\ncache_max_bytes = true",
|
|
|
|
|
'[push]\nmode = "nag"',
|
|
|
|
|
'[push]\nincomplete_coverage_policy = "ignore"',
|
|
|
|
|
'[push]\ncomparison_base = ""',
|
|
|
|
|
"[push]\ncomparison_base = 1",
|
|
|
|
|
"[push]\ndeadline_second = 60",
|
|
|
|
|
],
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
def test_push_configuration_rejects_unsafe_coercion(tmp_path: Path, setting: str) -> None:
|
|
|
|
|
path = tmp_path / "settings.toml"
|
|
|
|
|
path.write_text(setting)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
load_config(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_contract_round_trips_preserve_partition_ref_and_comparison_identity() -> None:
|
|
|
|
|
update = PushUpdate("refs/heads/topic", "a" * 64, "refs/heads/main", "b" * 64)
|
2026-09-12 21:12:45 +02:00
|
|
|
context = ChangeContext(
|
|
|
|
|
"b" * 64,
|
|
|
|
|
"a" * 64,
|
|
|
|
|
("src/api.js",),
|
|
|
|
|
("src/guard.js",),
|
|
|
|
|
(ChangedSpan("src/guard.js", 3, 8, "base"),),
|
|
|
|
|
(),
|
|
|
|
|
("package.json",),
|
|
|
|
|
(),
|
|
|
|
|
("dynamic dispatch",),
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
for value in (update, comparison(), context, scope()):
|
|
|
|
|
assert type(value).from_payload(json.loads(json.dumps(value.to_payload()))) == value
|
|
|
|
|
assert question() != question("php", "src/api.js")
|
|
|
|
|
assert question().question_id != question("php", "src/api.js").question_id
|
|
|
|
|
other_base = replace(comparison(), base_oid="c" * 64)
|
|
|
|
|
assert other_base != comparison()
|
|
|
|
|
with pytest.raises(FrozenInstanceError):
|
|
|
|
|
update.local_oid = "other" # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 21:12:45 +02:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"finding,coverage,disposition",
|
|
|
|
|
[
|
|
|
|
|
("none", "incomplete", "allow"),
|
|
|
|
|
("none", "unavailable", "block"),
|
|
|
|
|
("actionable", "incomplete", "allow"),
|
|
|
|
|
("actionable", "complete_within_scope", "block"),
|
|
|
|
|
],
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
def test_result_statuses_are_independent(finding: str, coverage: str, disposition: str) -> None:
|
|
|
|
|
payload = {
|
|
|
|
|
"analyses": [ComparisonAnalysis(comparison(), (scope(),), (question(),), coverage).to_payload()],
|
|
|
|
|
"finding_status": finding,
|
2026-09-12 21:12:45 +02:00
|
|
|
"coverage_status": coverage,
|
|
|
|
|
"push_disposition": disposition,
|
2026-09-12 19:21:31 +02:00
|
|
|
"actionable_defect_ids": ["defect-1"] if finding == "actionable" else [],
|
|
|
|
|
}
|
|
|
|
|
result = PushResult.from_payload(payload)
|
|
|
|
|
assert PushResult.from_payload(json.loads(json.dumps(result.to_payload()))) == result
|
|
|
|
|
assert result.finding_status == finding
|
|
|
|
|
assert result.coverage_status == coverage
|
|
|
|
|
assert result.push_disposition == disposition
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_incomplete_scope_cannot_be_serialized_as_complete_negative() -> None:
|
|
|
|
|
result = ComparisonAnalysis(comparison(), (scope(),), (question(),), "complete_within_scope")
|
|
|
|
|
assert ComparisonAnalysis.from_payload(result.to_payload()) == result
|
|
|
|
|
deferred = DeferredQuestion(question("php", "index.php"), "deadline", ("unanswered",))
|
2026-09-12 21:12:45 +02:00
|
|
|
for changed in (
|
|
|
|
|
replace(scope(), deferred=(deferred,)),
|
|
|
|
|
replace(scope(), unresolved_boundaries=("vendor call",)),
|
|
|
|
|
replace(scope(), population_complete=False),
|
|
|
|
|
):
|
2026-09-12 19:21:31 +02:00
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(result, scopes=(changed,))
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(result, completed_questions=())
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(result, scopes=())
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(result, comparison=replace(comparison(), base_oid=None, base_reason="missing"))
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(scope(), deferred=(DeferredQuestion(question(), "deadline", ()),))
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 21:12:45 +02:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"value",
|
|
|
|
|
[
|
|
|
|
|
{"local_ref": "x", "local_oid": "a", "remote_ref": "y", "remote_oid": "b", "extra": 1},
|
|
|
|
|
{"local_ref": "x", "local_oid": 1, "remote_ref": "y", "remote_oid": "b"},
|
|
|
|
|
{"local_ref": "x", "local_oid": "a", "remote_ref": "y"},
|
|
|
|
|
],
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
def test_malformed_identity_payloads_fail(value: dict[str, object]) -> None:
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
PushUpdate.from_payload(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_graph_provenance_round_trip_and_live_lease_separation() -> None:
|
2026-09-12 21:12:45 +02:00
|
|
|
identity = GraphIdentity(
|
|
|
|
|
"source-digest",
|
|
|
|
|
"declarations",
|
|
|
|
|
"exclusions",
|
|
|
|
|
"api",
|
|
|
|
|
"javascript",
|
|
|
|
|
"4.0.625",
|
|
|
|
|
"jssrc2cpg",
|
|
|
|
|
"4.0.625",
|
|
|
|
|
("dataflowOss-v1",),
|
|
|
|
|
(("option", "value"),),
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
artifact = GraphArtifact(identity, "graph-sha256", 128, GraphCensus(1, 2, 3), "complete", ())
|
|
|
|
|
assert GraphArtifact.from_payload(json.loads(json.dumps(artifact.to_payload()))) == artifact
|
|
|
|
|
assert replace(identity, exclusions_digest="changed") != identity
|
|
|
|
|
assert not issubclass(GraphLease, Contract) # a manifest cannot restore a live resource
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(artifact, source_bytes=0)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(artifact, diagnostics=("file unreadable",))
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(artifact, census=GraphCensus(0, 0, 0))
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(identity, overlay_options=(("x", "1"), ("x", "2")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_strict_runtime_construction_not_only_decoding() -> None:
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
RankedQuestion(question(), float("nan"), ())
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
ChangedSpan("x", True, 3, "head")
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
ChangedSpan("x", 4, 3, "head")
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
PushConfig(deadline_seconds=True)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
PushConfig.from_payload({"mode": "blocking", "incomplete_coverage_policy": []})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_generic_contracts_and_engine_do_not_depend_on_push() -> None:
|
|
|
|
|
root = Path("src/openultrasast")
|
|
|
|
|
paths = [root / "contracts.py", *(root / "model").glob("*.py"), *(root / "cpg").glob("*.py")]
|
|
|
|
|
for path in paths:
|
|
|
|
|
for node in ast.walk(ast.parse(path.read_text())):
|
|
|
|
|
if isinstance(node, ast.ImportFrom):
|
|
|
|
|
assert "push" not in (node.module or "").split("."), path
|
|
|
|
|
elif isinstance(node, ast.Import):
|
|
|
|
|
assert all("push" not in alias.name.split(".") for alias in node.names), path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_shared_head_does_not_merge_different_base_completion() -> None:
|
|
|
|
|
completed = ComparisonAnalysis(comparison(), (scope(),), (question(),), "complete_within_scope")
|
|
|
|
|
pending = ComparisonAnalysis(replace(comparison(), base_oid="c" * 64), (scope(),), (), "incomplete")
|
|
|
|
|
result = PushResult((completed, pending), "none", "incomplete", "allow", ())
|
|
|
|
|
restored = PushResult.from_payload(json.loads(json.dumps(result.to_payload())))
|
|
|
|
|
assert restored == result
|
|
|
|
|
assert restored.analyses[1].completed_questions == ()
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(result, coverage_status="complete_within_scope")
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(pending, coverage_status="complete_within_scope")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_deletion_only_result_is_explicitly_not_applicable() -> None:
|
|
|
|
|
result = PushResult((), "none", "not_applicable", "allow", ())
|
|
|
|
|
assert PushResult.from_payload(result.to_payload()) == result
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(result, coverage_status="complete_within_scope")
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 21:12:45 +02:00
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"field,value",
|
|
|
|
|
[
|
|
|
|
|
("finding_status", "clean"),
|
|
|
|
|
("coverage_status", "secure"),
|
|
|
|
|
("push_disposition", "warn"),
|
|
|
|
|
("finding_status", True),
|
|
|
|
|
("coverage_status", None),
|
|
|
|
|
],
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
def test_result_rejects_unknown_status_vocabulary(field: str, value: object) -> None:
|
|
|
|
|
payload = PushResult((), "none", "not_applicable", "allow", ()).to_payload()
|
|
|
|
|
payload[field] = value
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
PushResult.from_payload(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_graph_partial_provenance_stays_partial_and_options_are_preserved() -> None:
|
2026-09-12 21:12:45 +02:00
|
|
|
identity = GraphIdentity(
|
|
|
|
|
"sources",
|
|
|
|
|
"decls",
|
|
|
|
|
"excludes",
|
|
|
|
|
"core",
|
|
|
|
|
"c",
|
|
|
|
|
"4.0.625",
|
|
|
|
|
"c2cpg",
|
|
|
|
|
"4.0.625",
|
|
|
|
|
("dataflowOss-v1",),
|
|
|
|
|
(("depth", "2"),),
|
|
|
|
|
(("include", "src"),),
|
|
|
|
|
)
|
2026-09-12 19:21:31 +02:00
|
|
|
artifact = GraphArtifact(identity, "digest", 0, GraphCensus(0, 0, 0), "partial", ("source unreadable",))
|
|
|
|
|
restored = GraphArtifact.from_payload(json.loads(json.dumps(artifact.to_payload())))
|
|
|
|
|
assert restored == artifact
|
|
|
|
|
assert restored.identity.frontend_options == (("include", "src"),)
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(restored, completeness="complete")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_execution_budget_requires_finite_clock_and_distinct_positive_allowance() -> None:
|
|
|
|
|
from openultrasast.model.contracts import ExecutionBudget
|
|
|
|
|
|
|
|
|
|
budget = ExecutionBudget(123.0, 2.0)
|
|
|
|
|
assert budget.deadline_monotonic == 123
|
|
|
|
|
assert budget.cancellation_allowance_seconds == 2
|
|
|
|
|
for deadline, allowance in ((float("inf"), 2), (1, float("nan")), (True, 2), (1, 0), (-1, 2)):
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
ExecutionBudget(deadline, allowance)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_population_needs_an_explicit_census_assertion() -> None:
|
|
|
|
|
empty = ScopeDecision("core-v1", "evidence", True, (), (), ())
|
|
|
|
|
analyzed = ComparisonAnalysis(comparison(), (empty,), (), "complete_within_scope")
|
|
|
|
|
assert analyzed.completed_questions == ()
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
replace(analyzed, scopes=(replace(empty, population_complete=False),))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_direct_mutable_collection_is_not_an_immutable_contract() -> None:
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
PushComparison("head", "base", "supplied", ["refs/heads/main"]) # type: ignore[arg-type]
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
PushResult.from_payload({"finding_status": "none"})
|