6Verification backpressure — the deterministic inner-loop feedback sensor (plan 137).
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.
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.
31 L2 — lifecycle events (verification start/result)
32 L3 — semantic details (per-tool classification, counts)
33 L4 — deep tracing (argv, parse details)
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``.
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)
58from __future__
import annotations
64from collections
import Counter
65from dataclasses
import dataclass, field
66from pathlib
import Path
67from typing
import Callable
69from oc_diagnostics
import _dbg
71_dbg(
"OC_CHAT.MAIN",
"verification",
"module", 4)
76INFRA_ERROR =
"infra_error"
78UNPARSEABLE =
"unparseable"
82_PYRIGHT_RE = re.compile(
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*$"
88_RULE_RE = re.compile(
r"\(([A-Za-z][A-Za-z0-9]+)\)\s*$")
90_POS_RE = re.compile(
r"\b\d+\b")
93@dataclass(frozen=True)
95 """A single verification finding (lint check failure or type error)."""
101 severity: str =
"blocking"
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."""
112 """Typed outcome of one :func:`run_verification` call (plan 137 §3.3)."""
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
125 """Raw result of one subprocess invocation (or a non-completion sentinel)."""
127 returncode: int |
None
130 error: str |
None =
None
134Runner = Callable[[list[str], int], _CmdOutcome]
138 if explicit
is not None:
139 return Path(explicit)
142 return find_workspace_root()
148 """Full argv for an ``oct`` subcommand (``python -m oct`` — PATH-independent)."""
149 return [sys.executable,
"-m",
"oct", *args]
153 """Run *argv* with a hard timeout; classify non-completion (Windows-safe)."""
154 func =
"_default_runner"
160 proc = subprocess.run(
161 argv, capture_output=
True, text=
True, timeout=timeout, check=
False,
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)
174 """Workspace-root-relative POSIX path (pyright emits absolute paths)."""
178 p = p.relative_to(root)
180 except (ValueError, OSError):
181 return str(path).replace(
"\\",
"/")
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)
192 """Parse the first JSON object in *stdout* (warnings may precede it)."""
197 data = json.loads(stdout[i:])
198 except (json.JSONDecodeError, ValueError):
200 return data
if isinstance(data, dict)
else None
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):
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):
213 if str(chk.get(
"severity",
"blocking")) !=
"blocking":
216 path=path, line=
None, code=str(code),
217 message=str(chk.get(
"message",
"")),
218 severity=
"blocking", tool=
"lint",
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":
231 rule = _RULE_RE.search(msg)
234 message = _RULE_RE.sub(
"", msg).strip()
236 code, message =
"type-error", msg.strip()
238 path=
_norm_path(m.group(
"path"), root), line=int(m.group(
"line")),
239 code=code, message=message, severity=
"blocking", tool=
"typecheck",
244def _classify_lint(outcome: _CmdOutcome, root: Path) -> tuple[str, list[Finding]]:
245 if outcome.error ==
"timeout":
247 if outcome.error ==
"missing_binary":
248 return INFRA_ERROR, []
252 return (INFRA_ERROR
if (outcome.returncode
or 0) != 0
else UNPARSEABLE), []
254 return (FINDINGS
if findings
else CLEAN), findings
258 if outcome.error ==
"timeout":
260 if outcome.error ==
"missing_binary":
261 return INFRA_ERROR, []
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)
269 return INFRA_ERROR, []
271 return (FINDINGS
if findings
else UNPARSEABLE), findings
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():
284 for cand
in root.glob(f
"**/tests/**/test_{stem}.py"):
285 targets.append(cand.relative_to(root).as_posix())
287 seen: set[str] = set()
288 return [t
for t
in targets
if not (t
in seen
or seen.add(t))]
292 if outcome.error ==
"timeout":
294 if outcome.error ==
"missing_binary":
296 rc = outcome.returncode
or 0
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)."""
309 remaining: Counter = Counter(baseline)
310 new: list[Finding] = []
313 if remaining.get(k, 0) > 0:
321 changed_files: list[str],
322 baseline:
"Counter[tuple[str, str, str]] | None",
323 cfg:
"VerificationConfig",
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).
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.
335 func =
"run_verification"
336 run = runner
or _default_runner
338 targets = sorted({str(p).replace(
"\\",
"/")
for p
in changed_files
if str(p).strip()})
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)
346 skip_reason=f
"too many changed files ({len(targets)} > {cfg.max_files})")
348 _dbg(
"OC_CHAT.MAIN", func, f
"verifying {len(targets)} file(s) profile={cfg.profile}", 2)
350 findings: list[Finding] = []
352 lint_status = typecheck_status = tests_status = SKIPPED
359 _oct_argv([
"lint", *targets,
"--profile", cfg.profile,
360 "--json",
"--jobs", str(cfg.jobs),
"--no-cache"]),
364 findings.extend(lint_findings)
365 degraded = degraded
or lint_status
in (INFRA_ERROR, TIMEOUT, UNPARSEABLE)
367 outcome = run(
_oct_argv([
"typecheck", *targets,
"--json"]), cfg.timeout_s)
369 findings.extend(tc_findings)
370 degraded = degraded
or typecheck_status
in (INFRA_ERROR, TIMEOUT, UNPARSEABLE)
374 outcome = run([sys.executable,
"-m",
"pytest", *test_targets,
"-q"], cfg.timeout_s)
376 degraded = degraded
or tests_status
in (INFRA_ERROR, TIMEOUT)
387 clean=
not new_findings,
388 findings=new_findings,
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,
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)
402 cfg:
"VerificationConfig",
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.
412 func =
"capture_baseline"
413 run = runner
or _default_runner
416 keys: Counter = Counter()
418 _dbg(
"OC_CHAT.MAIN", func,
"no baseline scope resolved; empty baseline", 2)
421 outcome = run(
_oct_argv([
"lint", *scope,
"--profile", cfg.profile,
422 "--json",
"--jobs", str(cfg.jobs),
"--no-cache"]),
423 cfg.baseline_timeout_s)
428 outcome = run(
_oct_argv([
"typecheck", *scope,
"--json"]), cfg.baseline_timeout_s)
432 _dbg(
"OC_CHAT.MAIN", func, f
"baseline captured: {len(keys)} distinct finding key(s)", 2)
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."""
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"}
446 for child
in sorted(root.iterdir()):
447 if not child.is_dir()
or child.name
in skip
or child.name.startswith(
"."):
449 if any(child.rglob(
"*.py")):
450 out.append(child.name)
457 """Compact the result into the ``[verification]`` message body (plan 137 §3.5).
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.
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})
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):"
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
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)
484 tool_args: dict |
None,
487 workspace_root: str | Path |
None =
None,
489 """Workspace-root-relative paths a **successful** mutating tool wrote (§3.4).
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.
500 if not isinstance(result, dict)
or result.get(
"error"):
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:
513 args = tool_args
if isinstance(tool_args, dict)
else {}
514 if args.get(
"dry_run"):
516 path = args.get(
"path")
519 out: list[str] = [str(path)]
524 seen: set[str] = set()
525 return [p
for p
in out
if p
and not (p
in seen
or seen.add(p))]
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
535 parent /
"tests" / f
"test_{stem}.py",
536 Path(
"tests") / f
"test_{stem}.py",
537 Path(
"tests") /
"unit" / f
"test_{stem}.py",
539 return [c.as_posix()
for c
in candidates
if (root / c).exists()]
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)."""
549 if rel.is_absolute():
550 rel = rel.relative_to(root)
551 except (ValueError, OSError):
553 for parent
in rel.parents:
554 if parent == Path(
"."):
556 marker = parent /
"__init__.py"
557 if (root / marker).exists():
558 out.append(marker.as_posix())
563 fn = getattr(presenter,
"spinner_start",
None)
573 fn = getattr(presenter,
"spinner_stop",
None)
583 "VerificationResult",
586 "render_observation",
587 "changed_paths_from_result",
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)