mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(pre-push-safety-net): preserve exact ranker scope and outcomes
This commit is contained in:
@@ -890,7 +890,19 @@ def _model_payload(
|
||||
honesty requirement (Req 3.3); NAMING them is what makes an exclusion list for a large repository
|
||||
something a maintainer can write from evidence rather than guesswork.
|
||||
"""
|
||||
scope = getattr(result, "scope", None)
|
||||
outcomes = getattr(result, "question_outcomes", ())
|
||||
if scope is not None:
|
||||
unjudged_paths = tuple(
|
||||
dict.fromkeys(
|
||||
[q.identity.path for q in scope.deferred if q.reason != "tier_zero"]
|
||||
+ [q.identity.path for q in outcomes if q.status != "completed"]
|
||||
)
|
||||
)[:10]
|
||||
return {
|
||||
"scope": scope.to_payload() if scope is not None else None,
|
||||
"question_outcomes": [q.to_payload() for q in outcomes],
|
||||
"family_coverage": [c.to_payload() for c in getattr(result, "family_coverage", ())],
|
||||
"by_rung": dict(getattr(result, "by_rung", {}) or {}),
|
||||
"regions_scanned": getattr(result, "regions_scanned", 0),
|
||||
"regions_unjudged": getattr(result, "regions_unjudged", 0),
|
||||
@@ -967,10 +979,8 @@ def _run_model_layer(
|
||||
for degradation in result.degradations:
|
||||
runtime.state["degradations"].append(dict(degradation))
|
||||
|
||||
# The regions the budget did not reach, weakest first -- the evidence an exclusion list is built from.
|
||||
unjudged = tuple(region.path for region in regions[result.regions_scanned :][:10])
|
||||
findings = [_finding_from_model(item) for item in result.findings]
|
||||
return findings, _model_payload(result, unjudged_paths=unjudged, coverage=coverage)
|
||||
return findings, _model_payload(result, coverage=coverage)
|
||||
|
||||
|
||||
def _model_rationale(item: object, witness: str, family: str) -> str:
|
||||
|
||||
@@ -145,6 +145,8 @@ class RankedQuestion(Contract):
|
||||
identity: QuestionIdentity
|
||||
priority: float
|
||||
evidence: tuple[str, ...]
|
||||
tier: int | None = None
|
||||
score: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -169,3 +171,49 @@ class ScopeDecision(Contract):
|
||||
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")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuestionOutcome(Contract):
|
||||
"""Execution, not vulnerability disposition; raw answers survive interrupted arbitration."""
|
||||
|
||||
identity: QuestionIdentity
|
||||
status: Literal["completed", "unanswered", "unresolved", "not_arbitrated"]
|
||||
reason: str
|
||||
raw_rows_json: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
if self.raw_rows_json is not None:
|
||||
try:
|
||||
rows = json.loads(self.raw_rows_json)
|
||||
except (ValueError, TypeError) as error:
|
||||
raise ValueError("raw_rows_json must contain a JSON row list") from error
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("raw_rows_json must contain a JSON row list")
|
||||
if self.status in {"completed", "not_arbitrated"} and self.raw_rows_json is None:
|
||||
raise ValueError("answered outcome requires raw rows")
|
||||
if self.status == "unanswered" and self.raw_rows_json is not None:
|
||||
raise ValueError("unanswered outcome cannot carry an answer")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FamilyCoverage(Contract):
|
||||
"""Counts over the supplied population; completed never means safe or admitted."""
|
||||
|
||||
unit: str
|
||||
language: str
|
||||
family: str
|
||||
selected: int
|
||||
completed: int
|
||||
unanswered: int
|
||||
deferred: int
|
||||
unsupported: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
counts = (self.selected, self.completed, self.unanswered, self.deferred, self.unsupported)
|
||||
if any(count < 0 for count in counts):
|
||||
raise ValueError("coverage counts must be nonnegative")
|
||||
if self.completed + self.unanswered != self.selected or self.unsupported > self.deferred:
|
||||
raise ValueError("inconsistent family coverage counts")
|
||||
|
||||
+188
-29
@@ -19,17 +19,18 @@ the region's risk score said beforehand.
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field, replace
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..cpg.backend import CpgResult
|
||||
from .candidates import enumerate_candidates
|
||||
from .config_value import request_params as config_params
|
||||
from .contracts import ExecutionBudget
|
||||
from .contracts import DeferredQuestion, ExecutionBudget, FamilyCoverage, QuestionIdentity, QuestionOutcome, RankedQuestion, ScopeDecision
|
||||
from .dominance import request_params as dominance_params
|
||||
from .evidence import TIER_EXCLUDE, Evidence, evidence_from_rows
|
||||
from .ladder import Rung
|
||||
@@ -59,6 +60,7 @@ ENTRY_POINT_CALL_DEPTH = 3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_RUNG_ORDER = {Rung.ENTAILED: 0, Rung.CORROBORATED: 1, Rung.SUSPICION: 2, Rung.EXECUTION_CONFIRMED: -1}
|
||||
|
||||
|
||||
@@ -115,6 +117,10 @@ class ModelScanResult:
|
||||
# could have been found was skipped. Reported so the request count is legible.
|
||||
requests_pruned: int = 0
|
||||
|
||||
scope: ScopeDecision | None = None
|
||||
question_outcomes: tuple[QuestionOutcome, ...] = ()
|
||||
family_coverage: tuple[FamilyCoverage, ...] = ()
|
||||
|
||||
@property
|
||||
def arbitrate_seconds(self) -> float:
|
||||
"""What is left once the CPG is built and queried: the judge calls and the arbiters themselves."""
|
||||
@@ -130,8 +136,21 @@ def scan_repository(
|
||||
model: str = "",
|
||||
budget: ScanBudget | None = None,
|
||||
execution_budget: ExecutionBudget | None = None,
|
||||
ranking_mode: str | None = None,
|
||||
unit: str = "repository",
|
||||
population_complete: bool = False,
|
||||
) -> ModelScanResult:
|
||||
"""Run with an optional transaction deadline; graph ownership ends on every exit."""
|
||||
if ranking_mode not in (None, "static", "evidence"):
|
||||
raise ValueError("ranking_mode must be static or evidence")
|
||||
limits = budget if budget is not None else ScanBudget()
|
||||
if ranking_mode is not None:
|
||||
limits = replace(limits, order_by_evidence=ranking_mode == "evidence")
|
||||
mode = "evidence" if limits.order_by_evidence else "static"
|
||||
# Reject duplicate canonical questions before any engine work or resource ownership.
|
||||
identities = [_identity(unit, r, f) for r in regions for f in r.families]
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("duplicate question identity")
|
||||
started = time.monotonic()
|
||||
owned: list[Any] = []
|
||||
try:
|
||||
@@ -141,7 +160,10 @@ def scan_repository(
|
||||
backend=backend,
|
||||
client=client if execution_budget is None else None,
|
||||
model=model,
|
||||
budget=budget,
|
||||
budget=limits,
|
||||
ranking_mode=mode,
|
||||
unit=unit,
|
||||
population_complete=population_complete,
|
||||
execution_budget=execution_budget,
|
||||
owned=owned,
|
||||
)
|
||||
@@ -172,6 +194,9 @@ def _scan_repository_impl(
|
||||
budget: ScanBudget | None = None,
|
||||
execution_budget: ExecutionBudget | None = None,
|
||||
owned: list[Any],
|
||||
ranking_mode: str,
|
||||
unit: str,
|
||||
population_complete: bool,
|
||||
) -> ModelScanResult:
|
||||
"""Arbitrate a repository's regions under one CPG and one budget."""
|
||||
limits = budget if budget is not None else ScanBudget()
|
||||
@@ -188,7 +213,10 @@ def _scan_repository_impl(
|
||||
cpg = _build(backend, root, dominant, execution_budget=execution_budget)
|
||||
build_seconds = round(time.monotonic() - build_started, 2)
|
||||
if cpg is None:
|
||||
scope, _ = _scope_work(regions, {}, limits, unit, ranking_mode, population_complete, failure="cpg_build_failed")
|
||||
return ModelScanResult(
|
||||
scope=scope,
|
||||
family_coverage=_family_coverage(scope, ()),
|
||||
by_rung=_empty_tally(),
|
||||
regions_unjudged=len(regions),
|
||||
seconds=round(time.monotonic() - started, 2),
|
||||
@@ -252,7 +280,7 @@ def _scan_repository_impl(
|
||||
else ordered_regions[: limits.max_regions]
|
||||
)
|
||||
tier_started = time.monotonic()
|
||||
evidence_by_pair, failed = _evidence_pass(cpg, _collect(evidence_candidates), hooks)
|
||||
evidence_by_pair, failed = _evidence_pass(cpg, _collect(evidence_candidates), hooks, degradations=degradations)
|
||||
per_kind["evidence"] = round(time.monotonic() - tier_started, 2)
|
||||
if failed:
|
||||
degradations.append({"stage": "model", "reason": "query_failed", "kind": "evidence", "requests": failed})
|
||||
@@ -260,11 +288,15 @@ def _scan_repository_impl(
|
||||
if limits.order_by_evidence and evidence_by_pair:
|
||||
ordered_regions = order_by_evidence(ordered_regions, evidence_by_pair)
|
||||
|
||||
# Phase 1: collect the whole scan's questions, for at most `max_regions` regions. Nothing is asked yet.
|
||||
work = _collect(ordered_regions[: limits.max_regions])
|
||||
tier_by_rid = {
|
||||
rid: evidence_by_pair[_pair_key(region, spec)].tier for rid, region, spec in work if _pair_key(region, spec) in evidence_by_pair
|
||||
}
|
||||
# Authoritative selection is made once, from the actual final ranker order.
|
||||
# Stable question IDs are the IDs sent to every execution request below.
|
||||
scope_limits = (
|
||||
replace(limits, prune_tier0=False)
|
||||
if any(d.get("reason") in {"files_unparsed", "cpg_empty", "cpg_sharded"} for d in degradations)
|
||||
else limits
|
||||
)
|
||||
scope, work = _scope_work(ordered_regions, evidence_by_pair, scope_limits, unit, ranking_mode, population_complete)
|
||||
outcomes: dict[str, QuestionOutcome] = {}
|
||||
|
||||
# Phase 2: ONE invocation per query kind. JVM startup dominates a repository scan -- a call per region
|
||||
# per family put a ten-line file at four minutes and a thousand regions at roughly fifty hours -- while
|
||||
@@ -272,19 +304,10 @@ def _scan_repository_impl(
|
||||
rows_by_id: dict[str, list[object]] = {}
|
||||
census_reported = False
|
||||
query_started = time.monotonic()
|
||||
pruned = 0
|
||||
pruned = sum(q.reason == "tier_zero" for q in scope.deferred)
|
||||
for kind, requests in _grouped(work, hook_callbacks=hooks).items():
|
||||
if not _within_deadline(execution_budget):
|
||||
break
|
||||
if kind == "taint" and limits.prune_tier0 and tier_by_rid:
|
||||
# Tier 0 is exact: a family with no sink in reach cannot yield a flow. Measured on a 637-file
|
||||
# plugin, that is 2,161 of 2,500 requests -- 86% of a query that timed out at forty minutes.
|
||||
keep = {rid: params for rid, params in requests.items() if tier_by_rid.get(rid, TIER_EXCLUDE + 1) != TIER_EXCLUDE}
|
||||
pruned = len(requests) - len(keep)
|
||||
requests = keep
|
||||
if not requests:
|
||||
per_kind[kind] = 0.0
|
||||
continue
|
||||
kind_started = time.monotonic()
|
||||
batch = getattr(cpg, "run_batch", None)
|
||||
if callable(batch):
|
||||
@@ -314,7 +337,11 @@ def _scan_repository_impl(
|
||||
if shards > 1:
|
||||
degradations.append({"stage": "model", "reason": "cpg_sharded", "shards": shards})
|
||||
census_reported = True
|
||||
rows_by_id.update(answered_batch)
|
||||
valid = {rid: rows for rid, rows in answered_batch.items() if rid in requests and isinstance(rows, list)}
|
||||
rows_by_id.update(valid)
|
||||
missing = len(requests) - len(valid)
|
||||
if missing:
|
||||
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": missing})
|
||||
else: # a backend without batching still works, one call at a time
|
||||
for rid, params in requests.items():
|
||||
if not _within_deadline(execution_budget):
|
||||
@@ -322,8 +349,6 @@ def _scan_repository_impl(
|
||||
answered = cpg.run(kind, params)
|
||||
if isinstance(answered, list):
|
||||
rows_by_id[rid] = list(answered)
|
||||
elif execution_budget is None:
|
||||
rows_by_id[rid] = []
|
||||
else:
|
||||
degradations.append({"stage": "model", "reason": "query_failed", "kind": kind, "requests": 1})
|
||||
per_kind[kind] = round(time.monotonic() - kind_started, 2)
|
||||
@@ -345,9 +370,8 @@ def _scan_repository_impl(
|
||||
for rid, region, spec in work:
|
||||
if not _within_deadline(execution_budget):
|
||||
break
|
||||
# Unanswered requests are deferred on the bounded path, never adjudicated
|
||||
# from an invented empty response. Exact identities are added in task 3.2.
|
||||
if execution_budget is not None and rid not in rows_by_id:
|
||||
# Unanswered requests are never adjudicated from invented empty responses.
|
||||
if rid not in rows_by_id:
|
||||
continue
|
||||
exhausted = counted is not None and counted.calls >= limits.max_model_calls
|
||||
family = getattr(spec, "family", "")
|
||||
@@ -379,7 +403,13 @@ def _scan_repository_impl(
|
||||
except Exception as exc: # noqa: BLE001 -- one bad region must not end the scan
|
||||
logger.warning("model scan failed for %s: %s", region.path, exc)
|
||||
degradations.append({"stage": "model", "reason": "region_failed", "path": region.path})
|
||||
outcomes[rid] = QuestionOutcome(
|
||||
_identity(unit, region, family), "unresolved", "arbitration_failed", _rows_json(rows_by_id[rid])
|
||||
)
|
||||
continue
|
||||
outcomes[rid] = QuestionOutcome(
|
||||
_identity(unit, region, family), "completed", "query_answered_and_arbitrated", _rows_json(rows_by_id[rid])
|
||||
)
|
||||
judged.add((region.path, region.function))
|
||||
if exhausted:
|
||||
unasked.add((region.path, region.function))
|
||||
@@ -388,6 +418,21 @@ def _scan_repository_impl(
|
||||
if unasked:
|
||||
degradations.append({"stage": "model", "reason": "budget_exhausted", "regions_unasked": len(unasked)})
|
||||
|
||||
graph_gaps = tuple(str(d["reason"]) for d in degradations if d.get("reason") in {"files_unparsed", "cpg_empty", "cpg_sharded"})
|
||||
for question in scope.selected:
|
||||
rid = question.identity.question_id
|
||||
if rid not in outcomes:
|
||||
answered = rid in rows_by_id
|
||||
outcomes[rid] = QuestionOutcome(
|
||||
question.identity,
|
||||
"not_arbitrated" if answered else "unanswered",
|
||||
"deadline_exhausted" if not _within_deadline(execution_budget) else "query_unanswered",
|
||||
_rows_json(rows_by_id[rid]) if answered else None,
|
||||
)
|
||||
if graph_gaps and outcomes[rid].status == "completed":
|
||||
outcomes[rid] = replace(outcomes[rid], status="unresolved", reason="graph_incomplete")
|
||||
scope = replace(scope, unresolved_boundaries=tuple(dict.fromkeys((*scope.unresolved_boundaries, *graph_gaps))))
|
||||
ordered_outcomes = tuple(outcomes[q.identity.question_id] for q in scope.selected)
|
||||
scanned = len(judged)
|
||||
|
||||
findings = _ordered(_deduplicated(collected))
|
||||
@@ -409,6 +454,100 @@ def _scan_repository_impl(
|
||||
tiers=tuple(tiers),
|
||||
tier_counts={tier: sum(1 for t in tiers if t[3] == tier) for tier in sorted({t[3] for t in tiers})},
|
||||
requests_pruned=pruned,
|
||||
scope=scope,
|
||||
question_outcomes=ordered_outcomes,
|
||||
family_coverage=_family_coverage(scope, ordered_outcomes),
|
||||
)
|
||||
|
||||
|
||||
def _identity(unit: str, region: ScanRegion, family: str) -> QuestionIdentity:
|
||||
return QuestionIdentity(unit, region.language, region.path, region.function, family)
|
||||
|
||||
|
||||
def _rows_json(rows: object) -> str:
|
||||
return json.dumps(rows, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _scope_work(
|
||||
regions: Sequence[ScanRegion],
|
||||
evidence: Mapping[tuple[str, str, str], Evidence],
|
||||
limits: ScanBudget,
|
||||
unit: str,
|
||||
mode: str,
|
||||
population_complete: bool,
|
||||
*,
|
||||
failure: str | None = None,
|
||||
) -> tuple[ScopeDecision, list[tuple[str, ScanRegion, ArbiterSpec]]]:
|
||||
"""Consume the ranker's final order once; the returned work IS selected scope."""
|
||||
selected: list[RankedQuestion] = []
|
||||
deferred: list[DeferredQuestion] = []
|
||||
work: list[tuple[str, ScanRegion, ArbiterSpec]] = []
|
||||
boundaries: list[str] = [] if population_complete else ["population_not_asserted_complete"]
|
||||
if failure:
|
||||
boundaries.append(failure)
|
||||
for index, region in enumerate(regions):
|
||||
for family in region.families:
|
||||
identity = _identity(unit, region, family)
|
||||
spec = _spec_for(family, region.language)
|
||||
vector = evidence.get((region.path, region.function or "", family))
|
||||
facts: tuple[str, ...] = ("static_rank=" + str(region.rank),)
|
||||
if vector is not None:
|
||||
facts += ("normalized_evidence=" + _rows_json(asdict(vector)), "tier=" + str(vector.tier), "score=" + str(vector.score))
|
||||
elif isinstance(spec, TaintSpec):
|
||||
facts += ("evidence_unknown",)
|
||||
boundaries.append("evidence_unknown:" + identity.question_id)
|
||||
else:
|
||||
facts += ("taint_evidence_not_applicable",)
|
||||
reason = (
|
||||
"unsupported_family"
|
||||
if spec is None
|
||||
else failure
|
||||
or (
|
||||
"region_budget"
|
||||
if index >= limits.max_regions
|
||||
else "tier_zero"
|
||||
if limits.prune_tier0 and isinstance(spec, TaintSpec) and vector is not None and vector.tier == TIER_EXCLUDE
|
||||
else None
|
||||
)
|
||||
)
|
||||
if reason:
|
||||
deferred.append(DeferredQuestion(identity, reason, facts))
|
||||
if reason == "unsupported_family":
|
||||
boundaries.append("unsupported_family:" + identity.question_id)
|
||||
else:
|
||||
assert spec is not None
|
||||
selected.append(
|
||||
RankedQuestion(
|
||||
identity,
|
||||
float(-len(selected)),
|
||||
facts,
|
||||
vector.tier if vector is not None else None,
|
||||
vector.score if vector is not None else None,
|
||||
)
|
||||
)
|
||||
work.append((identity.question_id, region, spec))
|
||||
return ScopeDecision(
|
||||
"existing-static-evidence-v1", mode, population_complete, tuple(selected), tuple(deferred), tuple(boundaries)
|
||||
), work
|
||||
|
||||
|
||||
def _family_coverage(scope: ScopeDecision, outcomes: tuple[QuestionOutcome, ...]) -> tuple[FamilyCoverage, ...]:
|
||||
population: list[RankedQuestion | DeferredQuestion] = [*scope.selected, *scope.deferred]
|
||||
keys = sorted({(q.identity.unit, q.identity.language, q.identity.family) for q in population})
|
||||
|
||||
def matches(identity: QuestionIdentity, key: tuple[str, str, str]) -> bool:
|
||||
return (identity.unit, identity.language, identity.family) == key
|
||||
|
||||
return tuple(
|
||||
FamilyCoverage(
|
||||
*key,
|
||||
selected=sum(matches(q.identity, key) for q in scope.selected),
|
||||
completed=sum(matches(q.identity, key) and q.status == "completed" for q in outcomes),
|
||||
unanswered=sum(matches(q.identity, key) and q.status != "completed" for q in outcomes),
|
||||
deferred=sum(matches(q.identity, key) for q in scope.deferred),
|
||||
unsupported=sum(matches(q.identity, key) and q.reason == "unsupported_family" for q in scope.deferred),
|
||||
)
|
||||
for key in keys
|
||||
)
|
||||
|
||||
|
||||
@@ -429,7 +568,7 @@ def _collect(regions: Sequence[ScanRegion]) -> list[tuple[str, ScanRegion, Arbit
|
||||
|
||||
|
||||
def _evidence_pass(
|
||||
cpg: Any, work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], hooks: str
|
||||
cpg: Any, work: Sequence[tuple[str, ScanRegion, ArbiterSpec]], hooks: str, *, degradations: list[Mapping[str, object]] | None = None
|
||||
) -> tuple[dict[tuple[str, str, str], Evidence], int]:
|
||||
"""The evidence vector of every taint pair in ``work``, keyed by (path, function, family).
|
||||
|
||||
@@ -443,12 +582,23 @@ def _evidence_pass(
|
||||
answered = cpg.run_batch("taint", requests)
|
||||
if answered is None:
|
||||
return {}, len(requests)
|
||||
answered.pop("__census__", None)
|
||||
census = answered.pop("__census__", None)
|
||||
if census and degradations is not None:
|
||||
entry = census[0] if isinstance(census[0], Mapping) else {}
|
||||
try:
|
||||
methods = int(str(entry.get("methods", "0")))
|
||||
shards = int(str(entry.get("shards", "1")))
|
||||
except ValueError:
|
||||
methods, shards = 0, 1
|
||||
if methods <= 0:
|
||||
degradations.append({"stage": "model", "reason": "cpg_empty"})
|
||||
if shards > 1:
|
||||
degradations.append({"stage": "model", "reason": "cpg_sharded", "shards": shards})
|
||||
by_rid = {rid: (region, spec) for rid, region, spec in work}
|
||||
out: dict[tuple[str, str, str], Evidence] = {}
|
||||
for rid, rows in answered.items():
|
||||
pair = by_rid.get(rid)
|
||||
if pair is None:
|
||||
if pair is None or rid not in requests or not isinstance(rows, list):
|
||||
continue
|
||||
region, spec = pair
|
||||
evidence = evidence_from_rows(
|
||||
@@ -463,7 +613,16 @@ def _evidence_pass(
|
||||
)
|
||||
if evidence is not None:
|
||||
out[_pair_key(region, spec)] = evidence
|
||||
return out, 0
|
||||
# Legacy ranker keys omit language. An overlapping frontend population cannot
|
||||
# borrow another language's vector; partitioned callers retain distinct IDs.
|
||||
languages_by_pair: dict[tuple[str, str, str], set[str]] = {}
|
||||
for rid, region, spec in work:
|
||||
if rid in requests:
|
||||
languages_by_pair.setdefault(_pair_key(region, spec), set()).add(region.language)
|
||||
for key, languages in languages_by_pair.items():
|
||||
if len(languages) > 1:
|
||||
out.pop(key, None)
|
||||
return out, len(requests) - len(out)
|
||||
|
||||
|
||||
def order_by_evidence(regions: Sequence[ScanRegion], evidence: Mapping[tuple[str, str, str], Evidence]) -> list[ScanRegion]:
|
||||
|
||||
Reference in New Issue
Block a user