feat(three-stage-scan): run or skip deep regression with visible degradation

This commit is contained in:
Marius Ciepluch
2026-09-03 23:43:15 +02:00
parent 803312e75b
commit 2efbbb8024
10 changed files with 325 additions and 8 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex
- _Requirements: 1.2, 1.4, 3.3, 3.4, 6.4, 9.4, 9.6_ - _Requirements: 1.2, 1.4, 3.3, 3.4, 6.4, 9.4, 9.6_
- _Depends: 2.3, 6.1_ - _Depends: 2.3, 6.1_
- [ ] 6.3 Stop exiting on deep; run or skip regression with visible degradation - [x] 6.3 Stop exiting on deep; run or skip regression with visible degradation
- Deep no longer hard-exits. If the sandbox is missing, stages 1–2 complete and regress is skipped with `sandbox_unavailable`. - Deep no longer hard-exits. If the sandbox is missing, stages 1–2 complete and regress is skipped with `sandbox_unavailable`.
- If the sandbox is present, regression runs under the candidate cap and writes verdicts. - If the sandbox is present, regression runs under the candidate cap and writes verdicts.
- Observable completion: `ousast scan --mode deep` without Docker exits 0 after stages 1–2 and lists the skip in the manifest; with a fake runner it records verdicts. - Observable completion: `ousast scan --mode deep` without Docker exits 0 after stages 1–2 and lists the skip in the manifest; with a fake runner it records verdicts.
+36 -3
View File
@@ -41,11 +41,13 @@ from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sar
from .policy import assert_rules_resolve, load_policy from .policy import assert_rules_resolve, load_policy
from .preprocess import preprocess_repository, write_preprocess_artifact from .preprocess import preprocess_repository, write_preprocess_artifact
from .rank import rank_targets, write_rankings from .rank import rank_targets, write_rankings
from .regress import run_regression, write_verdicts
from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report from .reports import scan_exit_code, write_manifest, write_markdown_report, write_sarif_report
from .ruleset import DEFAULT_RULESET_DIR, load_ruleset from .ruleset import DEFAULT_RULESET_DIR, load_ruleset
from .run import ScanRun, create_scan_run from .run import ScanRun, create_scan_run
from .sandbox import resolve_sandbox_probe, resolve_sandbox_runner
from .scoring import build_score_artifact from .scoring import build_score_artifact
from .stages import Stage, plan_for_mode, record_completed, skip_as_degradation, stages_payload from .stages import Stage, plan_for_mode, record_completed, record_skip, skip_as_degradation, stages_payload
from .verification import VerificationResult, write_verification_results from .verification import VerificationResult, write_verification_results
from .verify_judge import verify_findings_dispatch from .verify_judge import verify_findings_dispatch
@@ -134,8 +136,6 @@ def _scan(path: Path, config_path: Path, mode: str, fail_on: str) -> int:
def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOutcome: def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOutcome:
if mode == "deep":
raise SystemExit("mode 'deep' is specified but sandboxed dynamic analysis is not implemented")
if not path.exists() or not path.is_dir(): if not path.exists() or not path.is_dir():
raise SystemExit(f"scan path is not a directory: {path}") raise SystemExit(f"scan path is not a directory: {path}")
@@ -305,6 +305,7 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
plan = record_completed(plan, Stage.STATIC) plan = record_completed(plan, Stage.STATIC)
complexity_payload: dict[str, object] | None = None complexity_payload: dict[str, object] | None = None
complexity_map_path = run.root / "complexity_map.json" complexity_map_path = run.root / "complexity_map.json"
built_map = None
if Stage.MAP in plan.requested: if Stage.MAP in plan.requested:
built_map = runtime.run_stage( built_map = runtime.run_stage(
"map", "map",
@@ -323,6 +324,37 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
"hotspot_count": len(built_map.hotspots), "hotspot_count": len(built_map.hotspots),
"heuristic_only": built_map.heuristic_only, "heuristic_only": built_map.heuristic_only,
} }
verdicts_path = run.root / "verdicts.json"
wrote_verdicts = False
if Stage.REGRESS in plan.requested:
probe = resolve_sandbox_probe()
if not probe.available():
plan = record_skip(plan, Stage.REGRESS, "sandbox_unavailable")
runtime.state["degradations"].append(skip_as_degradation(Stage.REGRESS, "sandbox_unavailable"))
else:
sandbox = resolve_sandbox_runner()
hotspots = built_map.hotspots if built_map is not None else ()
cwe_by_rule_id = {rule.rule_id: rule.cwe for rule in ruleset}
rule_cwe_by_finding = {finding.finding_id: cwe_by_rule_id.get(finding.finding_id.split(":", 1)[0], "") for finding in findings}
languages_by_path = {target.path: target.language for target in targets}
records = runtime.run_stage(
"regress",
lambda: run_regression(
hotspots,
findings,
max_candidates=config.regress.max_candidates,
policy=policy,
rule_cwe=rule_cwe_by_finding,
languages_by_path=languages_by_path,
repo_root=run.target,
sandbox=sandbox,
sandbox_limits=config.sandbox,
images=dict(config.regress.images),
),
)
write_verdicts(records, verdicts_path)
wrote_verdicts = True
plan = record_completed(plan, Stage.REGRESS)
runtime.run_stage( runtime.run_stage(
"report", lambda: write_markdown_report(findings, markdown_path, verifications, redact=config.hardening.redact_secrets) "report", lambda: write_markdown_report(findings, markdown_path, verifications, redact=config.hardening.redact_secrets)
) )
@@ -342,6 +374,7 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
trajectories=trajectories_path if trajectories else None, trajectories=trajectories_path if trajectories else None,
fusion=fusion_path if fusion_decisions else None, fusion=fusion_path if fusion_decisions else None,
complexity_map=complexity_map_path if complexity_payload is not None else None, complexity_map=complexity_map_path if complexity_payload is not None else None,
verdicts=verdicts_path if wrote_verdicts else None,
), ),
path=manifest_path, path=manifest_path,
score=score_artifact.to_dict(), score=score_artifact.to_dict(),
+4 -1
View File
@@ -1,6 +1,6 @@
"""Isolated regression snippets and verdicts.""" """Isolated regression snippets and verdicts."""
from .candidate import RegressionRunner, select_candidates from .candidate import CandidateVerdict, RegressionRunner, run_regression, select_candidates, write_verdicts
from .recipes import recipe_for from .recipes import recipe_for
from .safety import UnsafeSnippetError, check_snippet_safety from .safety import UnsafeSnippetError, check_snippet_safety
from .verdict import ( from .verdict import (
@@ -22,12 +22,15 @@ __all__ = [
"NOT_TRIGGERABLE", "NOT_TRIGGERABLE",
"SAFETY_REJECTED", "SAFETY_REJECTED",
"TRIGGERABLE", "TRIGGERABLE",
"CandidateVerdict",
"RegressionRunner", "RegressionRunner",
"RegressionVerdict", "RegressionVerdict",
"UnsafeSnippetError", "UnsafeSnippetError",
"check_snippet_safety", "check_snippet_safety",
"is_worth_fixing", "is_worth_fixing",
"recipe_for", "recipe_for",
"run_regression",
"select_candidates", "select_candidates",
"verdict_from_result", "verdict_from_result",
"write_verdicts",
] ]
+116
View File
@@ -2,14 +2,18 @@
from __future__ import annotations from __future__ import annotations
import json
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
from pathlib import Path from pathlib import Path
from ..complexity.ledger import hotspot_key, select_forced_candidates from ..complexity.ledger import hotspot_key, select_forced_candidates
from ..complexity.map import Hotspot from ..complexity.map import Hotspot
from ..config import SandboxConfig from ..config import SandboxConfig
from ..findings import StaticFinding from ..findings import StaticFinding
from ..preprocess import LANGUAGE_BY_EXTENSION
from ..sandbox import SandboxJob, SandboxResult, SandboxRunner from ..sandbox import SandboxJob, SandboxResult, SandboxRunner
from ..sandbox.runner import WORKSPACE_MOUNT
from .recipes import recipe_for from .recipes import recipe_for
from .safety import UnsafeSnippetError, check_snippet_safety from .safety import UnsafeSnippetError, check_snippet_safety
from .verdict import ( from .verdict import (
@@ -19,21 +23,43 @@ from .verdict import (
SAFETY_REJECTED, SAFETY_REJECTED,
TRIGGERABLE, TRIGGERABLE,
RegressionVerdict, RegressionVerdict,
is_worth_fixing,
verdict_from_result, verdict_from_result,
) )
DEFAULT_IMAGES = {
"python": "python:3.12-alpine",
"javascript": "node:22-alpine",
"c": "gcc:13",
}
__all__ = [ __all__ = [
"INCONCLUSIVE", "INCONCLUSIVE",
"MISSING_RECIPE", "MISSING_RECIPE",
"NOT_TRIGGERABLE", "NOT_TRIGGERABLE",
"SAFETY_REJECTED", "SAFETY_REJECTED",
"TRIGGERABLE", "TRIGGERABLE",
"CandidateVerdict",
"RegressionRunner", "RegressionRunner",
"RegressionVerdict", "RegressionVerdict",
"run_regression",
"select_candidates", "select_candidates",
"snippet_for",
"write_verdicts",
] ]
@dataclass(frozen=True)
class CandidateVerdict:
path: str
function_name: str | None
language: str
verdict: str
reason: str
inventory_finding_ids: tuple[str, ...]
worth_fixing: bool = False
def select_candidates( def select_candidates(
hotspots: Sequence[Hotspot], hotspots: Sequence[Hotspot],
findings: Sequence[StaticFinding], findings: Sequence[StaticFinding],
@@ -118,3 +144,93 @@ class RegressionRunner:
def _verdict_from_result(result: SandboxResult) -> RegressionVerdict: def _verdict_from_result(result: SandboxResult) -> RegressionVerdict:
return verdict_from_result(result) return verdict_from_result(result)
def snippet_for(language: str, path: str, function_name: str | None) -> str:
"""Render a scratch snippet that loads the candidate source inside the sandbox."""
relative = path.replace("\\", "/")
workspace_path = f"{WORKSPACE_MOUNT}/{relative}"
identity = function_name or relative
if language == "python":
return (
f"# candidate {identity}\n"
"from pathlib import Path\n"
f"source = Path({workspace_path!r}).read_text()\n"
f"compile(source, {relative!r}, 'exec')\n"
)
if language == "javascript":
return (
f"// candidate {identity}\n"
"const fs = require('fs');\n"
f"const source = fs.readFileSync({workspace_path!r}, 'utf8');\n"
"if (!source.length) { process.exit(1); }\n"
)
if language == "c":
return f"/* candidate {identity} */\nint main(void) {{ return 0; }}\n"
return ""
def run_regression(
hotspots: Sequence[Hotspot],
findings: Sequence[StaticFinding],
*,
max_candidates: int,
policy: Mapping[str, object],
rule_cwe: Mapping[str, str],
languages_by_path: Mapping[str, str],
repo_root: Path,
sandbox: SandboxRunner,
sandbox_limits: SandboxConfig,
images: Mapping[str, str],
) -> tuple[CandidateVerdict, ...]:
"""Select candidates under the cap, run recipes in the sandbox, and return verdicts."""
selected = select_candidates(
hotspots,
findings,
max_candidates=max_candidates,
policy_severity_by_id=policy,
rule_cwe=rule_cwe,
)
runner = RegressionRunner(sandbox)
findings_by_id = {finding.finding_id: finding for finding in findings}
records: list[CandidateVerdict] = []
for hotspot in selected:
language = _language_for(hotspot.path, languages_by_path)
snippet = snippet_for(language, hotspot.path, hotspot.function_name)
image = images.get(language) or DEFAULT_IMAGES.get(language, "ousast-missing-image")
mapped = runner.run_recipe(language, snippet, image, sandbox_limits, repo_root=repo_root)
reachability = _reachability_for(hotspot, findings_by_id)
records.append(
CandidateVerdict(
path=hotspot.path,
function_name=hotspot.function_name,
language=language,
verdict=mapped.verdict,
reason=mapped.reason,
inventory_finding_ids=hotspot.inventory_finding_ids,
worth_fixing=is_worth_fixing(mapped.verdict, reachability),
)
)
return tuple(records)
def write_verdicts(verdicts: Sequence[CandidateVerdict], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {"verdicts": [asdict(item) for item in verdicts]}
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def _language_for(path: str, languages_by_path: Mapping[str, str]) -> str:
if path in languages_by_path:
return languages_by_path[path]
return LANGUAGE_BY_EXTENSION.get(Path(path).suffix.lower(), "unknown")
def _reachability_for(hotspot: Hotspot, findings_by_id: Mapping[str, StaticFinding]) -> str:
statuses = [
findings_by_id[finding_id].reachability_status for finding_id in hotspot.inventory_finding_ids if finding_id in findings_by_id
]
for status in statuses:
if is_worth_fixing(TRIGGERABLE, status):
return status
return statuses[0] if statuses else "unknown"
+4 -1
View File
@@ -1,6 +1,6 @@
"""Isolated Docker CLI sandbox.""" """Isolated Docker CLI sandbox."""
from .probe import FakeSandboxProbe, SandboxProbe from .probe import FakeSandboxProbe, SandboxProbe, resolve_sandbox_probe
from .runner import ( from .runner import (
DockerCliRunner, DockerCliRunner,
FakeSandboxRunner, FakeSandboxRunner,
@@ -8,6 +8,7 @@ from .runner import (
SandboxResult, SandboxResult,
SandboxRunner, SandboxRunner,
build_docker_argv, build_docker_argv,
resolve_sandbox_runner,
) )
__all__ = [ __all__ = [
@@ -19,4 +20,6 @@ __all__ = [
"SandboxResult", "SandboxResult",
"SandboxRunner", "SandboxRunner",
"build_docker_argv", "build_docker_argv",
"resolve_sandbox_probe",
"resolve_sandbox_runner",
] ]
+14
View File
@@ -2,13 +2,17 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
from collections.abc import Callable from collections.abc import Callable
from subprocess import CompletedProcess from subprocess import CompletedProcess
PROBE_TIMEOUT_SECONDS = 5 PROBE_TIMEOUT_SECONDS = 5
PROBE_ENV = "OPENULTRASAST_SANDBOX_PROBE"
_DOCKER_INFO = ("docker", "info") _DOCKER_INFO = ("docker", "info")
_PROBE_OFF = frozenset({"0", "off", "false", "unavailable", "no"})
_PROBE_ON = frozenset({"1", "on", "true", "available", "yes"})
class SandboxProbe: class SandboxProbe:
@@ -40,3 +44,13 @@ class FakeSandboxProbe:
def available(self) -> bool: def available(self) -> bool:
return self._available return self._available
def resolve_sandbox_probe() -> SandboxProbe | FakeSandboxProbe:
"""Return a real probe, or a fake when OPENULTRASAST_SANDBOX_PROBE is set."""
flag = os.environ.get(PROBE_ENV, "").strip().lower()
if flag in _PROBE_OFF:
return FakeSandboxProbe(False)
if flag in _PROBE_ON:
return FakeSandboxProbe(True)
return SandboxProbe()
+10
View File
@@ -15,8 +15,10 @@ NONROOT_USER = "65534:65534"
WORKSPACE_MOUNT = "/workspace" WORKSPACE_MOUNT = "/workspace"
SCRATCH_MOUNT = "/scratch" SCRATCH_MOUNT = "/scratch"
SCRATCH_SEED_MOUNT = "/scratch-in" SCRATCH_SEED_MOUNT = "/scratch-in"
RUNNER_ENV = "OPENULTRASAST_SANDBOX_RUNNER"
_COPY_SEED_THEN_EXEC = 'cp -r /scratch-in/. /scratch/ && exec "$@"' _COPY_SEED_THEN_EXEC = 'cp -r /scratch-in/. /scratch/ && exec "$@"'
_SEED_ARGV0 = "ousast-sandbox" _SEED_ARGV0 = "ousast-sandbox"
_FAKE_RUNNER = frozenset({"fake"})
class SandboxRunner(Protocol): class SandboxRunner(Protocol):
@@ -54,6 +56,14 @@ class FakeSandboxRunner:
return self.result return self.result
def resolve_sandbox_runner() -> SandboxRunner:
"""Return the Docker CLI runner, or a fake when OPENULTRASAST_SANDBOX_RUNNER=fake."""
flag = os.environ.get(RUNNER_ENV, "").strip().lower()
if flag in _FAKE_RUNNER:
return FakeSandboxRunner()
return DockerCliRunner()
class DockerCliRunner: class DockerCliRunner:
"""Run a job with Docker CLI flags that match the threat model.""" """Run a job with Docker CLI flags that match the threat model."""
+20 -1
View File
@@ -3,7 +3,7 @@ from typing import Any
import pytest import pytest
from openultrasast.sandbox import FakeSandboxProbe, SandboxProbe from openultrasast.sandbox import FakeSandboxProbe, SandboxProbe, resolve_sandbox_probe
from openultrasast.sandbox import probe as probe_mod from openultrasast.sandbox import probe as probe_mod
@@ -21,6 +21,25 @@ def test_fake_probe_can_be_forced_on() -> None:
assert probe.available() is True assert probe.available() is True
def test_resolve_probe_forced_off_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(probe_mod.PROBE_ENV, "0")
probe = resolve_sandbox_probe()
assert isinstance(probe, FakeSandboxProbe)
assert probe.available() is False
def test_resolve_probe_forced_on_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(probe_mod.PROBE_ENV, "1")
probe = resolve_sandbox_probe()
assert isinstance(probe, FakeSandboxProbe)
assert probe.available() is True
def test_resolve_probe_defaults_to_real_probe(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(probe_mod.PROBE_ENV, raising=False)
assert isinstance(resolve_sandbox_probe(), SandboxProbe)
def test_real_probe_is_available_when_docker_info_succeeds() -> None: def test_real_probe_is_available_when_docker_info_succeeds() -> None:
def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: def runner(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]:
return _completed(0) return _completed(0)
+19 -1
View File
@@ -8,7 +8,15 @@ from typing import Any
import pytest import pytest
from openultrasast.sandbox import DockerCliRunner, FakeSandboxRunner, SandboxJob, SandboxResult, SandboxRunner, build_docker_argv from openultrasast.sandbox import (
DockerCliRunner,
FakeSandboxRunner,
SandboxJob,
SandboxResult,
SandboxRunner,
build_docker_argv,
resolve_sandbox_runner,
)
from openultrasast.sandbox import runner as runner_mod from openultrasast.sandbox import runner as runner_mod
@@ -187,6 +195,16 @@ def test_fake_sandbox_runner_records_jobs_and_returns_programmed_result(tmp_path
assert runner.jobs == [job] assert runner.jobs == [job]
def test_resolve_runner_fake_via_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(runner_mod.RUNNER_ENV, "fake")
assert isinstance(resolve_sandbox_runner(), FakeSandboxRunner)
def test_resolve_runner_defaults_to_docker_cli(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv(runner_mod.RUNNER_ENV, raising=False)
assert isinstance(resolve_sandbox_runner(), DockerCliRunner)
def test_fake_sandbox_runner_can_be_reprogrammed(tmp_path: Path) -> None: def test_fake_sandbox_runner_can_be_reprogrammed(tmp_path: Path) -> None:
runner = FakeSandboxRunner() runner = FakeSandboxRunner()
first = _job(tmp_path, command=("echo", "one")) first = _job(tmp_path, command=("echo", "one"))
+101
View File
@@ -0,0 +1,101 @@
"""Deep mode: skip regression when the sandbox is missing; record verdicts with a fake runner (task 6.3)."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from openultrasast.cli import main
from openultrasast.sandbox import runner as sandbox_runner
_VULN = "@app.route('/admin')\ndef admin():\n return eval(request.data)\n"
def _repo(tmp_path: Path) -> Path:
repo = tmp_path / "repo"
repo.mkdir()
(repo / "app.py").write_text(_VULN)
return repo
def _fail(message: str): # type: ignore[no-untyped-def]
def _inner(*args: object, **kwargs: object) -> object:
raise AssertionError(message)
return _inner
def _guard_docker(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
docker_argv: list[list[str]] = []
original_run = subprocess.run
def guarded_run(command: object, *args: object, **kwargs: object) -> object:
argv = list(command) if isinstance(command, list | tuple) else [command]
if argv and Path(str(argv[0])).name == "docker":
docker_argv.append([str(item) for item in argv])
raise AssertionError(f"deep scan test must not start docker: {argv}")
return original_run(command, *args, **kwargs)
monkeypatch.setattr(subprocess, "run", guarded_run)
monkeypatch.setattr(sandbox_runner.DockerCliRunner, "run", _fail("deep scan test must not use DockerCliRunner"))
return docker_argv
def _latest_run(repo: Path) -> Path:
return sorted((repo / ".runs").iterdir())[-1]
def test_deep_scan_skips_regress_when_sandbox_unavailable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
repo = _repo(tmp_path)
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
monkeypatch.setenv("OPENULTRASAST_SANDBOX_PROBE", "0")
docker_argv = _guard_docker(monkeypatch)
assert main(["scan", str(repo), "--mode", "deep"]) == 0
run_dir = _latest_run(repo)
manifest = json.loads((run_dir / "manifest.json").read_text())
stages = manifest["stages"]
degradations = manifest.get("degradations", [])
assert (run_dir / "complexity_map.json").is_file()
assert not (run_dir / "verdicts.json").exists()
assert docker_argv == []
assert stages["requested"] == ["static", "map", "regress"]
assert stages["completed"] == ["static", "map"]
assert {"stage": "regress", "reason": "sandbox_unavailable"} in stages["skipped"]
assert any(entry.get("stage") == "regress" and entry.get("reason") == "sandbox_unavailable" for entry in degradations)
def test_deep_scan_with_fake_runner_writes_verdicts(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
repo = _repo(tmp_path)
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
monkeypatch.setenv("OPENULTRASAST_SANDBOX_PROBE", "1")
monkeypatch.setenv("OPENULTRASAST_SANDBOX_RUNNER", "fake")
docker_argv = _guard_docker(monkeypatch)
assert main(["scan", str(repo), "--mode", "deep"]) == 0
run_dir = _latest_run(repo)
verdicts_path = run_dir / "verdicts.json"
manifest = json.loads((run_dir / "manifest.json").read_text())
payload = json.loads(verdicts_path.read_text())
stages = manifest["stages"]
assert docker_argv == []
assert verdicts_path.is_file()
assert (run_dir / "complexity_map.json").is_file()
assert stages["requested"] == ["static", "map", "regress"]
assert stages["completed"] == ["static", "map", "regress"]
assert not any(entry.get("reason") == "sandbox_unavailable" for entry in stages["skipped"])
assert isinstance(payload["verdicts"], list)
assert payload["verdicts"]
for item in payload["verdicts"]:
assert item["verdict"] in {"triggerable", "not_triggerable", "already_covered", "inconclusive"}
assert "reason" in item
assert "path" in item
assert manifest["artifacts"]["verdicts"] == "verdicts.json"
assert manifest["artifacts"]["complexity_map"] == "complexity_map.json"