"""Scan orchestration: for every item, find the latest version, gather evidence, and produce a verdict.""" from __future__ import annotations import logging import uuid from datetime import timezone from . import coupling from .config import Config from .http import HttpClient from .models import Finding, Item, RunSummary, utcnow from .risk import collectors, decide, gather_evidence from .risk.llm import ClaudeVerdictEngine from .sources import check_item from .sources.base import Context from .store import Store from .versions import bump_kind, compare log = logging.getLogger(__name__) class Scanner: def __init__(self, cfg: Config, store: Store, offline: bool = False): self.cfg = cfg self.store = store self.http = HttpClient( cache_dir=cfg.cache_dir, cache_ttl=cfg.cache_ttl, timeout=cfg.http_timeout, offline=offline, ) # The websearch source reads its config and the lookup cache out of # the shared scratch, so it can stay a plain Source like the others. self.ctx = Context(http=self.http, scratch={ "lookup_cfg": cfg.lookup, "store": store, "lookup_calls": 0, }) self.engine = ClaudeVerdictEngine(cfg.llm) self.reddit = collectors.RedditClient( self.http, cfg.risk.reddit_client_id, cfg.risk.reddit_client_secret ) # ---------------------------------------------------------------------- def scan( self, items: list[Item], mode: str = "scan", llm_policy: str = "new-only", only: list[str] | None = None, ) -> tuple[list[Finding], RunSummary]: """Run a full pass. ``llm_policy``: new-only -- ask Claude only about versions seen for the first time (the cheap default for mid-week scans) all -- ask about every item we are behind on (the Monday digest) none -- heuristics only """ if only: wanted = set(only) items = [i for i in items if i.id in wanted] if llm_policy == "none": # --no-llm means no paid calls at all, lookups included. self.ctx.scratch["lookup_cfg"] = None if self.http.offline: self.ctx.scratch["lookup_cfg"] = None run = RunSummary( run_id=uuid.uuid4().hex[:12], started=utcnow(), mode=mode ) self.store.start_run(run) findings: list[Finding] = [] for n, item in enumerate(items, 1): log.info("[%d/%d] %s", n, len(items), item.name) findings.append(self._scan_one(item, run.run_id, llm_policy)) # Coupling has to run after every item is scanned -- it compares # items against each other, so it cannot be done item-by-item. by_id = {i.id: i for i in items} problems = coupling.validate(by_id) for p in problems: log.warning("coupling: %s", p) coupling.annotate(findings, by_id) n_coupled = sum(1 for f in findings if f.coupling) if n_coupled: log.info("coupling: %d item(s) carry version dependencies, " "%d blocking", n_coupled, sum(1 for f in findings if f.has_blocking_coupling)) # Re-save so the stored verdict reflects the downgrade. for f in findings: if f.coupling: self.store.save_finding(run.run_id, f) run.finished = utcnow() run.items_checked = len(findings) run.updates_found = sum(1 for f in findings if f.comparison == "behind") run.errors = sum(1 for f in findings if f.check.status == "error") run.new_since_last = sum(1 for f in findings if f.is_new_since_last_run) run.http_requests = self.http.stats["requests"] run.cache_hits = self.http.stats["cache_hits"] self.store.finish_run(run) log.info( "run %s: %d checked, %d behind, %d new, %d errors " "(%d requests, %d cache hits, %d verdict calls, %d web lookups)", run.run_id, run.items_checked, run.updates_found, run.new_since_last, run.errors, run.http_requests, run.cache_hits, self.engine.calls, self.ctx.scratch.get("lookup_calls", 0), ) return findings, run # ---------------------------------------------------------------------- def _scan_one(self, item: Item, run_id: str, llm_policy: str) -> Finding: check = check_item(item, self.ctx) is_new = self.store.record_sighting(check) comparison = ( compare(item.installed, check.latest) if check.status == "ok" else "unknown" ) bump = ( bump_kind(item.installed, check.latest) if check.status == "ok" else "unknown" ) finding = Finding( item=item, check=check, comparison=comparison, bump=bump, is_new_since_last_run=is_new, ) # Community scan: only where it can change the answer. want_evidence = self.cfg.risk.enabled and ( comparison == "behind" or (check.status == "manual" and not self.cfg.risk.only_when_behind) or not self.cfg.risk.only_when_behind ) if want_evidence: finding.evidence = gather_evidence( item, check, self.cfg, self.http, self.reddit ) # How long has this version been known to exist? Used when the vendor # publishes no release date. first_seen_age = None if check.latest: seen = self.store.first_seen(item.id, check.latest) if seen: first_seen_age = ( utcnow() - seen.astimezone(timezone.utc) ).total_seconds() / 86400.0 use_llm = ( llm_policy == "all" or (llm_policy == "new-only" and is_new) ) finding.verdict = decide( item, check, comparison, bump, finding.evidence, self.cfg, self.engine, first_seen_age, use_llm=use_llm, ) self.store.save_finding(run_id, finding) return finding def sort_findings(findings: list[Finding]) -> list[Finding]: """Most-actionable first: things that need a decision, worst risk on top, then show-critical gear, then everything else.""" rec_rank = {"avoid": 0, "wait": 1, "manual-check": 2, "go": 3, "unknown": 4} crit_rank = {"showstopper": 0, "high": 1, "normal": 2, "low": 3} def key(f: Finding): v = f.verdict behind = 0 if f.comparison == "behind" else 1 return ( behind, rec_rank.get(v.recommendation if v else "unknown", 4), crit_rank.get(f.item.criticality, 2), -(v.risk_score if v else 0.0), f.item.name.lower(), ) return sorted(findings, key=key)