oc_chat
Loading...
Searching...
No Matches
verification.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/verification.py
4
5"""
6Verification backpressure — the deterministic inner-loop feedback sensor (plan 137).
7
8Purpose
9-------
10Run the *existing* computational controls (``oct lint`` + ``oct typecheck``, and
11an opt-in narrowest test selection) automatically over the files an iteration
12mutated, diff the result against a session-local baseline so only **new** findings
13surface, and return a compacted, typed :class:`VerificationResult` the caller
14injects back into the loop. The sensor runs **no model call** — it is deterministic
15subprocess work. It never blocks the turn: any infrastructure problem fails open.
16
17Responsibilities
18----------------
19- :func:`run_verification` — changed-files-only, time-boxed, classified, baseline-
20 diffed lint/typecheck/tests over **explicit targets** (never ``--changed``).
21- :func:`capture_baseline` — a session-local lint+typecheck snapshot (a finding
22 multiset) over a bounded scope, fail-open; never OCT's global baseline file.
23- :func:`render_observation` — compact the result into the ``[verification]``
24 message body (12-Factor #9 — never raw tracebacks).
25- :class:`Finding` / :class:`VerificationResult` — the typed shapes.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.MAIN
30Levels:
31 L2 — lifecycle events (verification start/result)
32 L3 — semantic details (per-tool classification, counts)
33 L4 — deep tracing (argv, parse details)
34
35Contracts
36---------
37- Pure of model calls. Subprocesses run with an argv list (Windows-safe), a hard
38 ``timeout``, and ``cwd`` = workspace root. A missing binary / rejected flag /
39 timeout / unparseable output / too-many-files classifies **degraded** (fail-open).
40- A non-zero exit **with parseable findings** is ``findings`` (a real failure to
41 surface), not fail-open.
42- Empty ``changed_files`` short-circuits to ``clean`` — ``oct lint`` is never
43 invoked with no targets (which would lint the whole workspace).
44- The baseline diff keys on the full ``(path, code, normalized-message)`` tuple
45 with count-based subtraction, so a line-shifted pre-existing finding stays
46 suppressed while a genuinely new message surfaces.
47- ``capture_baseline`` stores findings in-process only; it never runs
48 ``oct lint --baseline`` and never touches ``.option_c/cache/lint-baseline.json``.
49
50Dependencies
51------------
52- oc_diagnostics (structured debug logging via _dbg)
53- oc_chat.core._paths (lazy; workspace-root resolution)
54- oc_chat.tools._sandbox (lazy; scrubbed environment for the verification subprocesses)
55- oct (not imported; python -m oct lint and typecheck over the changed files and the session baseline scope)
56- pytest (not imported; python -m pytest over the companion test files)
57"""
58from __future__ import annotations
59
60import json
61import re
62import subprocess
63import sys
64from collections import Counter
65from dataclasses import dataclass, field
66from pathlib import Path
67from typing import Callable
68
69from oc_diagnostics import _dbg
70
71_dbg("OC_CHAT.MAIN", "verification", "module", 4)
72
73# Per-command classifications.
74CLEAN = "clean"
75FINDINGS = "findings"
76INFRA_ERROR = "infra_error"
77TIMEOUT = "timeout"
78UNPARSEABLE = "unparseable"
79SKIPPED = "skipped"
80
81#: pyright text line: `` <path>:<line>:<col> - error: <message> (reportRule)``.
82_PYRIGHT_RE = re.compile(
83 # OCT-LINT: disable=no_hardcoded_secrets reason="Regex for one pyright text output line; no credential material (operator-ruled 2026-09-28)"
84 r"^\s*(?P<path>.+?):(?P<line>\d+):(?P<col>\d+)\s*-\s*"
85 r"(?P<sev>error|warning|information)\s*:\s*(?P<msg>.*?)\s*$"
86)
87#: trailing ``(reportSomething)`` rule tag on a pyright message.
88_RULE_RE = re.compile(r"\‍(([A-Za-z][A-Za-z0-9]+)\‍)\s*$")
89#: digit runs and address-like tokens stripped for message normalisation.
90_POS_RE = re.compile(r"\b\d+\b")
91
92
93@dataclass(frozen=True)
94class Finding:
95 """A single verification finding (lint check failure or type error)."""
96
97 path: str
98 line: int | None
99 code: str
100 message: str
101 severity: str = "blocking"
102 tool: str = "lint" # "lint" | "typecheck" | "tests"
103
104 def key(self) -> tuple[str, str, str]:
105 """Baseline identity: ``(path, code, normalized-message)`` — NOT the line,
106 so a pre-existing finding shifted by inserted lines keeps its identity."""
107 return (self.path, self.code, _normalize_message(self.message))
108
109
110@dataclass
112 """Typed outcome of one :func:`run_verification` call (plan 137 §3.3)."""
113
114 clean: bool
115 findings: list[Finding] = field(default_factory=list)
116 degraded: bool = False
117 skip_reason: str | None = None
118 lint_status: str = SKIPPED
119 typecheck_status: str = SKIPPED
120 tests_status: str = SKIPPED
121
122
123@dataclass
125 """Raw result of one subprocess invocation (or a non-completion sentinel)."""
126
127 returncode: int | None
128 stdout: str = ""
129 stderr: str = ""
130 error: str | None = None # "timeout" | "missing_binary" | None
131
132
133# A runner is injectable so tests stub the subprocess hermetically.
134Runner = Callable[[list[str], int], _CmdOutcome]
135
136
137def _workspace_root(explicit: str | Path | None) -> Path:
138 if explicit is not None:
139 return Path(explicit)
140 try:
141 from oc_chat.core._paths import find_workspace_root
142 return find_workspace_root()
143 except Exception: # noqa: BLE001 — total by contract; never raise into the loop
144 return Path.cwd()
145
146
147def _oct_argv(args: list[str]) -> list[str]:
148 """Full argv for an ``oct`` subcommand (``python -m oct`` — PATH-independent)."""
149 return [sys.executable, "-m", "oct", *args]
150
151
152def _default_runner(argv: list[str], timeout: int) -> _CmdOutcome:
153 """Run *argv* with a hard timeout; classify non-completion (Windows-safe)."""
154 func = "_default_runner"
155 # M8.0 WU-3c: run verification subprocesses with a minimal scrubbed env
156 # (drop poisonable GIT_*/PIP_*/proxy/cert/PYTHONPATH/... families;
157 # F25/F72/F78/F79) rather than inheriting the full process env.
158 from oc_chat.tools._sandbox import scrubbed_env
159 try:
160 proc = subprocess.run(
161 argv, capture_output=True, text=True, timeout=timeout, check=False,
162 env=scrubbed_env(),
163 )
164 except subprocess.TimeoutExpired:
165 _dbg("OC_CHAT.MAIN", func, f"timeout after {timeout}s: {argv[:4]}", 3)
166 return _CmdOutcome(returncode=None, error="timeout")
167 except (FileNotFoundError, OSError) as exc:
168 _dbg("OC_CHAT.MAIN", func, f"missing binary / OS error: {type(exc).__name__}", 3)
169 return _CmdOutcome(returncode=None, error="missing_binary")
170 return _CmdOutcome(returncode=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
171
172
173def _norm_path(path: str, root: Path) -> str:
174 """Workspace-root-relative POSIX path (pyright emits absolute paths)."""
175 try:
176 p = Path(path)
177 if p.is_absolute():
178 p = p.relative_to(root)
179 return p.as_posix()
180 except (ValueError, OSError):
181 return str(path).replace("\\", "/")
182
183
184def _normalize_message(msg: str) -> str:
185 """Strip embedded positions/values so the message is not a second drift source."""
186 text = " ".join(str(msg).split())
187 text = _POS_RE.sub("N", text) # collapse line/col/size numbers to a placeholder
188 return text
189
190
191def _extract_json(stdout: str) -> dict | None:
192 """Parse the first JSON object in *stdout* (warnings may precede it)."""
193 i = stdout.find("{")
194 if i < 0:
195 return None
196 try:
197 data = json.loads(stdout[i:])
198 except (json.JSONDecodeError, ValueError):
199 return None
200 return data if isinstance(data, dict) else None
201
202
203def _parse_lint(data: dict, root: Path) -> list[Finding]:
204 """Blocking lint check failures from the ``files[].checks`` JSON shape."""
205 findings: list[Finding] = []
206 for f in data.get("files") or []:
207 if not isinstance(f, dict):
208 continue
209 path = _norm_path(str(f.get("path", "")), root)
210 for code, chk in (f.get("checks") or {}).items():
211 if not isinstance(chk, dict) or chk.get("pass", True):
212 continue
213 if str(chk.get("severity", "blocking")) != "blocking":
214 continue # advisory (e.g. tests_exist) is informational, not surfaced
215 findings.append(Finding(
216 path=path, line=None, code=str(code),
217 message=str(chk.get("message", "")),
218 severity="blocking", tool="lint",
219 ))
220 return findings
221
222
223def _parse_typecheck(output: str, root: Path) -> list[Finding]:
224 """pyright ``error`` lines from the wrapper's ``output`` text."""
225 findings: list[Finding] = []
226 for line in output.splitlines():
227 m = _PYRIGHT_RE.match(line)
228 if not m or m.group("sev") != "error":
229 continue
230 msg = m.group("msg")
231 rule = _RULE_RE.search(msg)
232 if rule:
233 code = rule.group(1)
234 message = _RULE_RE.sub("", msg).strip()
235 else:
236 code, message = "type-error", msg.strip()
237 findings.append(Finding(
238 path=_norm_path(m.group("path"), root), line=int(m.group("line")),
239 code=code, message=message, severity="blocking", tool="typecheck",
240 ))
241 return findings
242
243
244def _classify_lint(outcome: _CmdOutcome, root: Path) -> tuple[str, list[Finding]]:
245 if outcome.error == "timeout":
246 return TIMEOUT, []
247 if outcome.error == "missing_binary":
248 return INFRA_ERROR, []
249 data = _extract_json(outcome.stdout)
250 if data is None:
251 # No JSON: a rejected flag / unknown profile / usage error → infra; else unparseable.
252 return (INFRA_ERROR if (outcome.returncode or 0) != 0 else UNPARSEABLE), []
253 findings = _parse_lint(data, root)
254 return (FINDINGS if findings else CLEAN), findings
255
256
257def _classify_typecheck(outcome: _CmdOutcome, root: Path) -> tuple[str, list[Finding]]:
258 if outcome.error == "timeout":
259 return TIMEOUT, []
260 if outcome.error == "missing_binary":
261 return INFRA_ERROR, []
262 data = _extract_json(outcome.stdout)
263 if data is None:
264 return (INFRA_ERROR if (outcome.returncode or 0) not in (0, 1) else UNPARSEABLE), []
265 exit_code = int(data.get("exit_code", outcome.returncode or 0) or 0)
266 if exit_code == 0:
267 return CLEAN, []
268 if exit_code != 1:
269 return INFRA_ERROR, [] # pyright fatal/config error
270 findings = _parse_typecheck(str(data.get("output", "")), root)
271 return (FINDINGS if findings else UNPARSEABLE), findings
272
273
274def _map_test_targets(changed_files: list[str], root: Path) -> list[str]:
275 """Conservative changed-file→test mapping (OQ-4): ``test_<stem>.py`` under any
276 ``tests/`` dir. Never the full suite; missing mappings contribute nothing."""
277 targets: list[str] = []
278 for rel in changed_files:
279 stem = Path(rel).stem
280 if stem.startswith("test_"):
281 if (root / rel).exists():
282 targets.append(rel)
283 continue
284 for cand in root.glob(f"**/tests/**/test_{stem}.py"):
285 targets.append(cand.relative_to(root).as_posix())
286 # de-dup, preserve order
287 seen: set[str] = set()
288 return [t for t in targets if not (t in seen or seen.add(t))]
289
290
291def _classify_tests(outcome: _CmdOutcome) -> str:
292 if outcome.error == "timeout":
293 return TIMEOUT
294 if outcome.error == "missing_binary":
295 return INFRA_ERROR
296 rc = outcome.returncode or 0
297 if rc == 0:
298 return CLEAN
299 if rc == 5: # pytest: no tests collected — nothing to verify, not a failure
300 return CLEAN
301 return FINDINGS
302
303
304def _subtract_baseline(post: list[Finding], baseline: "Counter[tuple[str, str, str]] | None") -> list[Finding]:
305 """Return only findings whose ``(path, code, normalized-message)`` key is not
306 covered (count-wise) by *baseline* (plan 137 §3.8)."""
307 if not baseline:
308 return post
309 remaining: Counter = Counter(baseline)
310 new: list[Finding] = []
311 for f in post:
312 k = f.key()
313 if remaining.get(k, 0) > 0:
314 remaining[k] -= 1
315 else:
316 new.append(f)
317 return new
318
319
321 changed_files: list[str],
322 baseline: "Counter[tuple[str, str, str]] | None",
323 cfg: "VerificationConfig", # noqa: F821 — typed in settings; avoid an import cycle
324 *,
325 workspace_root: str | Path | None = None,
326 presenter: object | None = None,
327 runner: Runner | None = None,
328) -> VerificationResult:
329 """Verify *changed_files* and return only baseline-new findings (plan 137 §3.3).
330
331 Deterministic, time-boxed, classified, fail-open. Brackets the subprocess work
332 with a presenter spinner (no-op on plain presenters). Never streams raw output,
333 never blocks the turn, never calls a model.
334 """
335 func = "run_verification"
336 run = runner or _default_runner
337 root = _workspace_root(workspace_root)
338 targets = sorted({str(p).replace("\\", "/") for p in changed_files if str(p).strip()})
339
340 if not targets: # e.g. a turn whose every write_file failed (§3.3 / DS-3.1)
341 return VerificationResult(clean=True, skip_reason="no files changed",
342 lint_status=SKIPPED, typecheck_status=SKIPPED)
343 if len(targets) > cfg.max_files:
344 _dbg("OC_CHAT.MAIN", func, f"degraded: {len(targets)} > max_files {cfg.max_files}", 2)
345 return VerificationResult(clean=True, degraded=True,
346 skip_reason=f"too many changed files ({len(targets)} > {cfg.max_files})")
347
348 _dbg("OC_CHAT.MAIN", func, f"verifying {len(targets)} file(s) profile={cfg.profile}", 2)
349 spinner = _spinner_start(presenter, "verifying changes…")
350 findings: list[Finding] = []
351 degraded = False
352 lint_status = typecheck_status = tests_status = SKIPPED
353 try:
354 if cfg.lint:
355 # --no-cache: oct lint's cache is content-keyed and would return a STALE
356 # path for an identical-content file (verified) — the sensor needs the
357 # real changed-file path for the model and the baseline key (§3.3).
358 outcome = run(
359 _oct_argv(["lint", *targets, "--profile", cfg.profile,
360 "--json", "--jobs", str(cfg.jobs), "--no-cache"]),
361 cfg.timeout_s,
362 )
363 lint_status, lint_findings = _classify_lint(outcome, root)
364 findings.extend(lint_findings)
365 degraded = degraded or lint_status in (INFRA_ERROR, TIMEOUT, UNPARSEABLE)
366 if cfg.typecheck:
367 outcome = run(_oct_argv(["typecheck", *targets, "--json"]), cfg.timeout_s)
368 typecheck_status, tc_findings = _classify_typecheck(outcome, root)
369 findings.extend(tc_findings)
370 degraded = degraded or typecheck_status in (INFRA_ERROR, TIMEOUT, UNPARSEABLE)
371 if cfg.tests:
372 test_targets = _map_test_targets(targets, root)
373 if test_targets:
374 outcome = run([sys.executable, "-m", "pytest", *test_targets, "-q"], cfg.timeout_s)
375 tests_status = _classify_tests(outcome)
376 degraded = degraded or tests_status in (INFRA_ERROR, TIMEOUT)
377 else:
378 tests_status = CLEAN
379 finally:
380 _spinner_stop(presenter, spinner)
381
382 new_findings = _subtract_baseline(findings, baseline)
383 # ``clean`` means "nothing actionable for the model": no NEW findings (baseline-
384 # subtracted). A degraded run with no new findings is clean-but-degraded (a
385 # one-line skip note, never a block).
386 result = VerificationResult(
387 clean=not new_findings,
388 findings=new_findings,
389 degraded=degraded,
390 skip_reason=("verification degraded" if degraded and not new_findings else None),
391 lint_status=lint_status,
392 typecheck_status=typecheck_status,
393 tests_status=tests_status,
394 )
395 _dbg("OC_CHAT.MAIN", func,
396 f"result clean={result.clean} new={len(new_findings)} "
397 f"lint={lint_status} typecheck={typecheck_status} tests={tests_status} degraded={degraded}", 2)
398 return result
399
400
402 cfg: "VerificationConfig", # noqa: F821
403 *,
404 workspace_root: str | Path | None = None,
405 runner: Runner | None = None,
406) -> "Counter[tuple[str, str, str]]":
407 """Capture a session-local lint+typecheck finding multiset over the bounded
408 ``baseline_scope`` (plan 137 §3.8). In-process only; never ``oct lint
409 --baseline``; fail-open to an empty baseline (degraded ⇒ first findings simply
410 unsuppressed). Returns a ``Counter`` of finding keys.
411 """
412 func = "capture_baseline"
413 run = runner or _default_runner
414 root = _workspace_root(workspace_root)
415 scope = _resolve_baseline_scope(cfg.baseline_scope, root)
416 keys: Counter = Counter()
417 if not scope:
418 _dbg("OC_CHAT.MAIN", func, "no baseline scope resolved; empty baseline", 2)
419 return keys
420 if cfg.lint:
421 outcome = run(_oct_argv(["lint", *scope, "--profile", cfg.profile,
422 "--json", "--jobs", str(cfg.jobs), "--no-cache"]),
423 cfg.baseline_timeout_s)
424 status, findings = _classify_lint(outcome, root)
425 for f in findings:
426 keys[f.key()] += 1
427 if cfg.typecheck:
428 outcome = run(_oct_argv(["typecheck", *scope, "--json"]), cfg.baseline_timeout_s)
429 status, findings = _classify_typecheck(outcome, root)
430 for f in findings:
431 keys[f.key()] += 1
432 _dbg("OC_CHAT.MAIN", func, f"baseline captured: {len(keys)} distinct finding key(s)", 2)
433 return keys
434
435
436def _resolve_baseline_scope(scope: tuple[str, ...] | None, root: Path) -> list[str]:
437 """Bounded baseline targets. Explicit scope wins; else the top-level source
438 packages under the workspace root (dirs with a ``.py`` file, minus common
439 non-source dirs) — never an unbounded full-workspace scan."""
440 if scope:
441 return [s for s in scope if (root / s).exists()]
442 skip = {"__pycache__", "logs", "memory_store", "_backup", "nulldir",
443 "venv", ".venv", "_work", "sessions", ".git", "node_modules"}
444 out: list[str] = []
445 try:
446 for child in sorted(root.iterdir()):
447 if not child.is_dir() or child.name in skip or child.name.startswith("."):
448 continue
449 if any(child.rglob("*.py")):
450 out.append(child.name)
451 except OSError:
452 return []
453 return out
454
455
456def render_observation(result: VerificationResult, max_findings: int = 10) -> str:
457 """Compact the result into the ``[verification]`` message body (plan 137 §3.5).
458
459 A header line + the top ``max_findings`` items as ``path:line — code: message``
460 (truncated) + a one-line instruction. Never raw tracebacks or full JSON.
461 """
462 n_lint = sum(1 for f in result.findings if f.tool == "lint")
463 n_type = sum(1 for f in result.findings if f.tool == "typecheck")
464 n_test = sum(1 for f in result.findings if f.tool == "tests")
465 n_files = len({f.path for f in result.findings})
466 parts = [
467 f"[verification] {n_lint} new lint + {n_type} new type"
468 + (f" + {n_test} test" if n_test else "")
469 + f" finding(s) in {n_files} changed file(s):"
470 ]
471 for f in result.findings[:max_findings]:
472 loc = f"{f.path}:{f.line}" if f.line else f.path
473 msg = (f.message[:160] + "…") if len(f.message) > 160 else f.message
474 parts.append(f" - {loc} — {f.code}: {msg}")
475 extra = len(result.findings) - max_findings
476 if extra > 0:
477 parts.append(f" …and {extra} more.")
478 parts.append("Fix these in the changed files and re-verify, or explain why they are acceptable.")
479 return "\n".join(parts)
480
481
483 tool_name: str,
484 tool_args: dict | None,
485 result: object,
486 *,
487 workspace_root: str | Path | None = None,
488) -> list[str]:
489 """Workspace-root-relative paths a **successful** mutating tool wrote (§3.4).
490
491 From the tool's *result* (not its args): ``write_file`` adds ``actual_path``
492 when not chunked, else every ``chunks[*].chunk_path`` (the logical target is
493 never materialised in chunked mode); ``oct_new`` returns the generic
494 ``_run_oct`` shape, so on ``returncode == 0`` and not ``dry_run`` it attributes
495 the validated ``path`` arg, the ``tests/`` companion (when ``test=True``), and
496 any ancestor ``__init__.py`` package markers that exist post-call (newly-created
497 header-less markers are a real blocking lint failure; the baseline suppresses
498 pre-existing ones). Failed / dry-run / error results add nothing.
499 """
500 if not isinstance(result, dict) or result.get("error"):
501 return []
502 root = _workspace_root(workspace_root)
503 if tool_name == "write_file":
504 if result.get("chunked"):
505 return [c["chunk_path"] for c in (result.get("chunks") or [])
506 if isinstance(c, dict) and c.get("chunk_path")]
507 ap = result.get("actual_path")
508 return [str(ap)] if ap else []
509 if tool_name == "oct_new":
510 rc = result.get("returncode", 1)
511 if rc is None or int(rc) != 0:
512 return []
513 args = tool_args if isinstance(tool_args, dict) else {}
514 if args.get("dry_run"):
515 return []
516 path = args.get("path")
517 if not path:
518 return []
519 out: list[str] = [str(path)]
520 if args.get("test"):
521 out.extend(_companion_test_paths(str(path), root))
522 out.extend(_ancestor_package_markers(str(path), root))
523 # de-dup, preserve order
524 seen: set[str] = set()
525 return [p for p in out if p and not (p in seen or seen.add(p))]
526 return []
527
528
529def _companion_test_paths(path: str, root: Path) -> list[str]:
530 """Existence-checked ``test_<stem>.py`` companions for an ``oct new --test``
531 target (no path guessing — only paths that actually exist are attributed)."""
532 stem = Path(path).stem
533 parent = Path(path).parent
534 candidates = [
535 parent / "tests" / f"test_{stem}.py",
536 Path("tests") / f"test_{stem}.py",
537 Path("tests") / "unit" / f"test_{stem}.py",
538 ]
539 return [c.as_posix() for c in candidates if (root / c).exists()]
540
541
542def _ancestor_package_markers(path: str, root: Path) -> list[str]:
543 """Existing ``__init__.py`` markers along *path*'s ancestor package dirs, under
544 the workspace root (§3.4 — newly-created header-less ones surface; the baseline
545 suppresses pre-existing)."""
546 out: list[str] = []
547 try:
548 rel = Path(path)
549 if rel.is_absolute():
550 rel = rel.relative_to(root)
551 except (ValueError, OSError):
552 return []
553 for parent in rel.parents:
554 if parent == Path("."):
555 break
556 marker = parent / "__init__.py"
557 if (root / marker).exists():
558 out.append(marker.as_posix())
559 return out
560
561
562def _spinner_start(presenter: object | None, label: str) -> object | None:
563 fn = getattr(presenter, "spinner_start", None)
564 if callable(fn):
565 try:
566 return fn(label)
567 except Exception: # noqa: BLE001 — a presenter spinner must never break the turn
568 return None
569 return None
570
571
572def _spinner_stop(presenter: object | None, handle: object | None) -> None:
573 fn = getattr(presenter, "spinner_stop", None)
574 if callable(fn):
575 try:
576 fn(handle)
577 except Exception: # noqa: BLE001 — non-fatal
578 pass
579
580
581__all__ = [
582 "Finding",
583 "VerificationResult",
584 "run_verification",
585 "capture_baseline",
586 "render_observation",
587 "changed_paths_from_result",
588]
tuple[str, str, str] key(self)
list[str] _companion_test_paths(str path, Path root)
str _normalize_message(str msg)
list[str] _map_test_targets(list[str] changed_files, Path root)
_CmdOutcome _default_runner(list[str] argv, int timeout)
tuple[str, list[Finding]] _classify_lint(_CmdOutcome outcome, Path root)
str _norm_path(str path, Path root)
list[str] _oct_argv(list[str] args)
list[Finding] _subtract_baseline(list[Finding] post, "Counter[tuple[str, str, str]] | None" baseline)
"Counter[tuple[str, str, str]]" capture_baseline("VerificationConfig" cfg, *, str|Path|None workspace_root=None, Runner|None runner=None)
object|None _spinner_start(object|None presenter, str label)
None _spinner_stop(object|None presenter, object|None handle)
str _classify_tests(_CmdOutcome outcome)
list[Finding] _parse_lint(dict data, Path root)
Path _workspace_root(str|Path|None explicit)
list[str] _ancestor_package_markers(str path, Path root)
list[Finding] _parse_typecheck(str output, Path root)
tuple[str, list[Finding]] _classify_typecheck(_CmdOutcome outcome, Path root)
list[str] changed_paths_from_result(str tool_name, dict|None tool_args, object result, *, str|Path|None workspace_root=None)
VerificationResult run_verification(list[str] changed_files, "Counter[tuple[str, str, str]] | None" baseline, "VerificationConfig" cfg, *, str|Path|None workspace_root=None, object|None presenter=None, Runner|None runner=None)
dict|None _extract_json(str stdout)
str render_observation(VerificationResult result, int max_findings=10)
list[str] _resolve_baseline_scope(tuple[str,...]|None scope, Path root)