Coverage for src/keel/doctor.py: 100%
257 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""Pure diagnostics for ``keel doctor`` — a read-only health pass.
3This module is **pure**: no network, no wall-clock, no randomness. The caller
4(``keel.cli``) performs all I/O — fetching the latest version from PyPI, reading
5adapter markers off disk, loading config, probing state-path existence — and
6passes the already-gathered facts into :func:`run_doctor`, which classifies each
7check as ``ok`` / ``skipped`` / ``warn`` / ``fail`` and returns a structured,
8JSON-stable result. Every branch here is deterministic and unit-tested.
10Checks
11------
12``cli_version`` installed ``keel.__version__`` vs latest on PyPI (the
13 headline check — a silent downgrade is a ``fail``).
14``adapter_version`` ``keel_version=`` markers on installed adapter surfaces vs
15 the running CLI version.
16``orphan_adapters`` surfaces whose ``command=`` is no longer in the installed
17 keel (stale-marker orphans).
18``core_version`` ``core_version`` constraint from project.yaml vs the
19 installed CLI version.
20``state_paths`` existence/validity of the configured ledger + checkpoint
21 paths (advisory; missing == empty history, not a defect).
22``python_toolchain`` the interpreter the build gate will actually run on, its
23 version, and whether PyYAML imports there (advisory).
24``providers`` which delegates are usable on this machine — only when
25 ``--providers`` asked for the probe (#1011).
26``policy_labels`` the labels the project's policy pack (and keel's own
27 attribution vocabulary) declare, vs the labels that exist on
28 the repository (#1021).
29``github_cli`` ``gh`` on PATH and ``gh auth status`` — what a live run needs
30 to reach GitHub (#1334).
31``agent_hosts`` which agent host CLIs (:data:`AGENT_HOSTS`) are on PATH — a
32 lookup only; ``--providers`` is the deep probe (#1334).
33"""
35from __future__ import annotations
37import re
38from collections.abc import Iterable
39from dataclasses import dataclass, field
40from pathlib import PurePath
42SCHEMA_VERSION = "keel.doctor.v1"
44#: per-check status levels, ordered worst-last for summary roll-up. ``skipped`` is
45#: a *reported* outcome, not a passing one: a check that could not look (no config,
46#: no ``gh``, ``--offline``) says so instead of claiming ``ok``, and ranks with
47#: ``ok`` so it never moves the roll-up.
48_OK = "ok"
49_SKIPPED = "skipped"
50_WARN = "warn"
51_FAIL = "fail"
52_RANK = {_OK: 0, _SKIPPED: 0, _WARN: 1, _FAIL: 2}
54#: Agent host CLIs that can drive ``/keel:ship`` — the three built-in CLI vendors
55#: (:data:`keel.vocab.CLI_VENDORS`) plus ``cursor-agent``, whose plugin install
56#: ``docs/keel/install.md`` covers. ``agent_hosts`` only looks each one up on PATH.
57AGENT_HOSTS = ("claude", "codex", "cursor-agent", "agy")
59#: a release version: ``MAJOR.MINOR.PATCH`` with optional further dotted parts.
60_VERSION_RE = re.compile(r"^\d+(?:\.\d+)*$")
61#: the lowest Python keel supports — ``requires-python`` in ``pyproject.toml``.
62MIN_PYTHON = (3, 11)
63#: a ``core_version`` constraint: an optional operator (``^`` / ``~`` / ``>=`` /
64#: ``==``) followed by a dotted version. A bare version means exact match.
65_CONSTRAINT_RE = re.compile(r"^(?P<op>\^|~|>=|<=|>|<|==|=)?\s*(?P<version>\d+(?:\.\d+)*)$")
68@dataclass(frozen=True)
69class CheckResult:
70 """One diagnostic check outcome (JSON-stable via :meth:`as_dict`)."""
72 name: str
73 status: str
74 summary: str
75 detail: dict[str, object] = field(default_factory=dict)
77 def as_dict(self) -> dict[str, object]:
78 return {
79 "name": self.name,
80 "status": self.status,
81 "summary": self.summary,
82 "detail": dict(self.detail),
83 }
86def _parse_version(text: str) -> tuple[int, ...] | None:
87 """Parse a dotted release version into a comparable tuple (``None`` if unparseable)."""
88 if not isinstance(text, str) or not _VERSION_RE.match(text.strip()):
89 return None
90 return tuple(int(part) for part in text.strip().split("."))
93def _pad(a: tuple[int, ...], b: tuple[int, ...]) -> tuple[tuple[int, ...], tuple[int, ...]]:
94 """Right-pad the shorter tuple with zeros so the two compare component-wise."""
95 width = max(len(a), len(b))
96 return a + (0,) * (width - len(a)), b + (0,) * (width - len(b))
99def constraint_satisfied(installed: str, constraint: str) -> bool | None:
100 """Does ``installed`` satisfy the ``core_version`` ``constraint``?
102 Supports ``^`` (caret: same leading non-zero, ``>=``), ``~`` (tilde: same
103 major+minor, ``>=``), the comparison operators (``>=``, ``<=``, ``>``,
104 ``<``, ``==``/``=``), and a bare version (exact match). Returns ``None`` when
105 either side is unparseable so the caller can report ``unknown`` rather than
106 guess. Pure and deterministic.
107 """
108 inst = _parse_version(installed)
109 match = _CONSTRAINT_RE.match(constraint.strip()) if isinstance(constraint, str) else None
110 if inst is None or match is None:
111 return None
112 want = _parse_version(match.group("version"))
113 if want is None: # pragma: no cover - regex already guarantees a parseable version
114 return None
115 op = match.group("op") or "=="
116 pi, pw = _pad(inst, want)
117 if op == "^":
118 # caret: pin the most-significant non-zero component, then ``>=``.
119 lead = next((i for i, part in enumerate(want) if part != 0), len(want) - 1)
120 return pi[lead] == pw[lead] and pi[:lead] == pw[:lead] and pi >= pw
121 if op == "~":
122 # tilde: pin major+minor (or major when no minor given), then ``>=``.
123 pin = min(2, len(want))
124 return pi[:pin] == pw[:pin] and pi >= pw
125 if op in (">=",):
126 return pi >= pw
127 if op in (">",):
128 return pi > pw
129 if op in ("<=",):
130 return pi <= pw
131 if op in ("<",):
132 return pi < pw
133 return pi == pw # ``==`` / ``=`` / bare
136def _check_cli_version(installed: str, latest: str | None) -> CheckResult:
137 """Installed CLI vs latest on PyPI. Offline => ``warn`` (unknown); stale => ``fail``."""
138 if latest is None:
139 return CheckResult(
140 "cli_version",
141 _WARN,
142 f"installed {installed}; latest unknown (offline or PyPI unreachable)",
143 {"installed": installed, "latest": "unknown"},
144 )
145 inst, lat = _parse_version(installed), _parse_version(latest)
146 detail = {"installed": installed, "latest": latest}
147 if inst is None or lat is None:
148 return CheckResult(
149 "cli_version",
150 _WARN,
151 f"installed {installed}; latest {latest}; cannot compare versions",
152 detail,
153 )
154 pi, pl = _pad(inst, lat)
155 if pi < pl:
156 return CheckResult(
157 "cli_version",
158 _FAIL,
159 f"installed {installed} is behind latest {latest} — upgrade keel-workflow",
160 detail,
161 )
162 if pi > pl:
163 return CheckResult(
164 "cli_version",
165 _WARN,
166 f"installed {installed} is ahead of latest {latest} (pre-release or unpublished)",
167 detail,
168 )
169 return CheckResult(
170 "cli_version",
171 _OK,
172 f"installed {installed} is up to date",
173 detail,
174 )
177def _check_adapter_version(installed: str, markers: list[dict[str, object]]) -> CheckResult:
178 """Installed adapter ``keel_version`` markers vs the running CLI version."""
179 if not markers:
180 return CheckResult(
181 "adapter_version",
182 _WARN,
183 "no keel-generated adapter surfaces found under --root",
184 {"installed": installed, "surfaces": 0, "drift": []},
185 )
186 drift = []
187 for marker in markers:
188 marker_version = marker.get("keel_version")
189 if marker_version != installed:
190 drift.append(
191 {
192 "surface": marker.get("surface", ""),
193 "name": marker.get("name", ""),
194 "keel_version": marker_version,
195 }
196 )
197 if drift:
198 return CheckResult(
199 "adapter_version",
200 _WARN,
201 f"{len(drift)} of {len(markers)} adapter surface(s) drift from CLI {installed} "
202 "— run keel update-adapter",
203 {"installed": installed, "surfaces": len(markers), "drift": drift},
204 )
205 return CheckResult(
206 "adapter_version",
207 _OK,
208 f"all {len(markers)} adapter surface(s) match CLI {installed}",
209 {"installed": installed, "surfaces": len(markers), "drift": []},
210 )
213def _check_orphan_adapters(orphans: list[dict[str, object]]) -> CheckResult:
214 """Surfaces whose command is no longer in the installed keel (stale-marker orphans)."""
215 if not orphans:
216 return CheckResult(
217 "orphan_adapters",
218 _OK,
219 "no orphan adapter surfaces",
220 {"orphans": []},
221 )
222 return CheckResult(
223 "orphan_adapters",
224 _WARN,
225 f"{len(orphans)} orphan adapter surface(s) — command(s) no longer in installed keel",
226 {"orphans": list(orphans)},
227 )
230def _check_core_version(installed: str, core_version: str | None) -> CheckResult:
231 """``core_version`` constraint from project.yaml vs the installed CLI version."""
232 if core_version is None:
233 # Reported, not passed: with no config there is no constraint to hold the CLI to.
234 return CheckResult(
235 "core_version",
236 _SKIPPED,
237 "no project config given — core_version check skipped",
238 {"installed": installed, "core_version": None},
239 )
240 detail = {"installed": installed, "core_version": core_version}
241 satisfied = constraint_satisfied(installed, core_version)
242 if satisfied is None:
243 return CheckResult(
244 "core_version",
245 _WARN,
246 f"cannot evaluate core_version {core_version!r} against installed {installed}",
247 detail,
248 )
249 if not satisfied:
250 return CheckResult(
251 "core_version",
252 _FAIL,
253 f"installed {installed} does not satisfy core_version {core_version!r}",
254 detail,
255 )
256 return CheckResult(
257 "core_version",
258 _OK,
259 f"installed {installed} satisfies core_version {core_version!r}",
260 detail,
261 )
264def _check_state_paths(state_paths: list[dict[str, object]]) -> CheckResult:
265 """Advisory check on configured ledger/checkpoint paths — missing == empty history."""
266 if not state_paths:
267 # A config always resolves both paths, so an empty list means no config was given:
268 # nothing was looked at, which is ``skipped`` rather than a clean bill.
269 return CheckResult(
270 "state_paths",
271 _SKIPPED,
272 "no project config given — state paths not checked",
273 {"paths": []},
274 )
275 for entry in state_paths:
276 if entry.get("status") == "invalid":
277 present = sum(1 for e in state_paths if e.get("status") == "present")
278 return CheckResult(
279 "state_paths",
280 _WARN,
281 "one or more configured state paths are invalid",
282 {"paths": list(state_paths), "present": present},
283 )
284 present = sum(1 for e in state_paths if e.get("status") == "present")
285 return CheckResult(
286 "state_paths",
287 _OK,
288 f"{present} of {len(state_paths)} state path(s) present "
289 "(missing paths report as empty history)",
290 {"paths": list(state_paths), "present": present},
291 )
294def _check_python_toolchain(toolchain: dict[str, object] | None) -> CheckResult:
295 """Will the build gate's interpreter satisfy ``requires-python`` + PyYAML?
297 The facts (which interpreter the gate resolves to, its version, whether
298 ``yaml`` imports there) are gathered by the caller — this only classifies
299 them. Never a ``fail``: keel cannot know that a red gate is *this* problem,
300 only that the interpreter behind it would produce one. A ``warn`` names the
301 interpreter, so a `make test` that dies with a hundred syntax errors reads
302 as a 3.9 on PATH rather than as a regression in the tree (#1022).
303 """
304 if toolchain is None:
305 return CheckResult(
306 "python_toolchain",
307 _SKIPPED,
308 "build-gate interpreter not probed",
309 {},
310 )
311 detail = dict(toolchain)
312 if toolchain.get("configured") is False:
313 # Reported, not passed: there is no gate whose interpreter could be checked (#1328).
314 return CheckResult(
315 "python_toolchain",
316 _SKIPPED,
317 "no build gate configured (knobs.build_gate_cmd is unset) — no interpreter to check",
318 detail,
319 )
320 interpreter = toolchain.get("interpreter")
321 reason = str(toolchain.get("reason") or "no interpreter resolved")
322 if not interpreter:
323 return CheckResult(
324 "python_toolchain",
325 _WARN,
326 f"the build gate has no usable interpreter — {reason}",
327 detail,
328 )
329 version = toolchain.get("version")
330 parsed = _parse_version(version) if isinstance(version, str) else None
331 if parsed is None:
332 return CheckResult(
333 "python_toolchain",
334 _WARN,
335 f"the build gate runs on {interpreter}, whose version is unknown — {reason}",
336 detail,
337 )
338 minimum = ".".join(str(part) for part in MIN_PYTHON)
339 problems = []
340 if parsed < MIN_PYTHON:
341 problems.append(f"Python {version} is below the required {minimum}")
342 if not toolchain.get("yaml"):
343 problems.append("PyYAML is not importable there")
344 if problems:
345 return CheckResult(
346 "python_toolchain",
347 _WARN,
348 f"the build gate would run on {interpreter}: {'; '.join(problems)}",
349 detail,
350 )
351 return CheckResult(
352 "python_toolchain",
353 _OK,
354 f"the build gate runs on {interpreter} (Python {version}, PyYAML present)",
355 detail,
356 )
359def _within(child: str, parent: str) -> bool:
360 """Is ``child`` ``parent`` itself, or nested inside it? Pure path-part comparison."""
361 parent_parts = PurePath(parent).parts
362 return PurePath(child).parts[: len(parent_parts)] == parent_parts
365def _check_checkout_binding(module_path: str | None, checkout_root: str | None) -> CheckResult:
366 """Is the importable ``keel`` the checkout this command is pointed at?
368 ``pip install -e .`` writes a single source tree into site-packages for the
369 whole interpreter, so installing from a second checkout silently repoints
370 every other one: imports, the test suite, and coverage all follow the other
371 tree while the working directory suggests otherwise.
373 A mismatch is a ``warn``, never a ``fail`` — running against a deliberately
374 installed keel (a release, a pinned build) is legitimate, so this informs
375 without changing anyone's exit code.
376 """
377 if not checkout_root:
378 return CheckResult(
379 "checkout_binding",
380 _SKIPPED,
381 "not run against a keel checkout; binding not checked",
382 {"module_path": module_path, "checkout_root": None},
383 )
384 detail: dict[str, object] = {"module_path": module_path, "checkout_root": checkout_root}
385 if not module_path:
386 return CheckResult(
387 "checkout_binding",
388 _WARN,
389 "the importable keel could not be located",
390 detail,
391 )
392 if _within(module_path, checkout_root):
393 return CheckResult(
394 "checkout_binding",
395 _OK,
396 "importable keel resolves inside this checkout",
397 detail,
398 )
399 return CheckResult(
400 "checkout_binding",
401 _WARN,
402 f"importable keel resolves outside this checkout ({module_path}) — local runs "
403 "exercise that tree; reinstall with `pip install -e .` from here",
404 detail,
405 )
408def _check_providers(payload: dict[str, object]) -> CheckResult:
409 """Classify an already-probed provider report (#1011).
411 Pure, like every other check: :mod:`keel.providerprobe` did the PATH lookups,
412 the ``--version`` calls and the one loopback HTTP request, and hands the facts in.
414 A name clash is a **fail**: a registry entry that shadows a built-in vendor or a
415 project profile is a configuration error the operator has to resolve, and the
416 entry is not being used meanwhile. A malformed registry, or a machine where no
417 provider at all is usable, is a ``warn`` — keel still runs on its host agent.
418 """
419 available = int(payload.get("available", 0) or 0)
420 total = int(payload.get("total", 0) or 0)
421 errors = list(payload.get("errors") or [])
422 warnings = list(payload.get("warnings") or [])
423 detail = {
424 "available": available,
425 "total": total,
426 "registry_path": payload.get("registry_path"),
427 "registry_present": payload.get("registry_present", False),
428 "errors": errors,
429 "warnings": warnings,
430 }
431 summary = f"{available} of {total} provider(s) available"
432 if errors:
433 return CheckResult("providers", _FAIL, f"{summary}; {errors[0]}", detail)
434 if warnings:
435 return CheckResult("providers", _WARN, f"{summary}; {warnings[0]}", detail)
436 if not available:
437 return CheckResult(
438 "providers",
439 _WARN,
440 f"{summary} — no delegate is usable on this machine",
441 detail,
442 )
443 return CheckResult("providers", _OK, summary, detail)
446def _label_values(value: object) -> list[str]:
447 """The non-empty strings in a policy-pack label list (anything else is ignored)."""
448 if not isinstance(value, list):
449 return []
450 return [item.strip() for item in value if isinstance(item, str) and item.strip()]
453def _qualify(group: str, name: str) -> str:
454 """Qualify a bare vocabulary entry with its group: ``role`` + ``core`` -> ``role:core``.
456 A policy pack may spell a label either way — ``status: ["status:backlog"]`` carries
457 the group already, ``role: ["core"]`` does not — and both mean the same GitHub label.
458 Anything already carrying a ``:`` is taken as the full label name.
459 """
460 return name if ":" in name else f"{group}:{name}"
463def declared_labels(
464 policy_pack: object,
465 *,
466 attribution: Iterable[str] = (),
467) -> tuple[str, ...]:
468 """Every label name a project's policy pack requires to exist on its repository.
470 Three sources, all of them labels keel itself writes: ``policy_pack.labels.*``
471 (the status/priority/role vocabularies ship and triage apply),
472 ``policy_pack.scan.issue_labels.*`` (what the scan-and-file commands stamp on an
473 issue they open), and ``attribution`` — the ``agent:*`` / ``model:*`` names from
474 :func:`keel.agents.attribution_labels`, passed in so this module stays free of
475 config types. Pure and deterministic: the result is sorted and deduplicated.
476 """
477 pack = policy_pack if isinstance(policy_pack, dict) else {}
478 names: set[str] = set()
479 groups = pack.get("labels")
480 if isinstance(groups, dict):
481 for group, values in groups.items():
482 names.update(_qualify(str(group), name) for name in _label_values(values))
483 scan = pack.get("scan")
484 issue_labels = scan.get("issue_labels") if isinstance(scan, dict) else None
485 if isinstance(issue_labels, dict):
486 for values in issue_labels.values():
487 names.update(_label_values(values))
488 names.update(name.strip() for name in attribution if name.strip())
489 return tuple(sorted(names))
492def missing_labels(declared: Iterable[str], existing: Iterable[str]) -> tuple[str, ...]:
493 """The declared labels that do not exist on the repository.
495 Compared case-insensitively because GitHub label names are: creating ``Bug`` on a
496 repository that already has ``bug`` is rejected as a duplicate, so a case-only
497 difference is a label that exists, not one to create.
498 """
499 have = {name.strip().lower() for name in existing}
500 return tuple(sorted({name for name in declared if name.strip().lower() not in have}))
503#: Missing labels named in the check summary before the rest are counted.
504_LABELS_SHOWN = 5
507def _check_policy_labels(payload: dict[str, object] | None) -> CheckResult:
508 """Do the labels this project declares actually exist on its repository (#1021)?
510 ``ship`` and ``triage`` apply ``status:*`` / ``priority:*`` / ``role:*`` and the
511 ``agent:*`` / ``model:*`` attribution pair by name. GitHub rejects a label that was
512 never created, and the rejection surfaces as a failed ``gh`` call in the middle of a
513 run rather than as a diagnosis — keel's own repository ran for months with every one
514 of those labels missing and nothing said so.
516 Never a ``fail``: the caller cannot always look (no config, no ``gh`` on PATH,
517 ``--offline``, an unauthenticated or unreachable GitHub), and a check that could not
518 look reports ``skipped`` with the reason. Missing labels are a ``warn`` carrying the
519 exact ``gh label create`` commands, which ``keel doctor --fix`` runs for you.
520 """
521 if payload is None:
522 return CheckResult(
523 "policy_labels",
524 _SKIPPED,
525 "no project config given — no policy pack to check",
526 {},
527 )
528 declared = list(payload.get("declared") or [])
529 missing = list(payload.get("missing") or [])
530 repo = payload.get("repo")
531 detail: dict[str, object] = {
532 "repo": repo,
533 "declared": declared,
534 "missing": missing,
535 "commands": list(payload.get("commands") or []),
536 "existing": len(list(payload.get("existing") or [])),
537 }
538 if not payload.get("available"):
539 reason = str(payload.get("reason") or "repository labels not read")
540 return CheckResult("policy_labels", _SKIPPED, reason, detail)
541 if missing:
542 shown = ", ".join(missing[:_LABELS_SHOWN])
543 extra = f", +{len(missing) - _LABELS_SHOWN} more" if len(missing) > _LABELS_SHOWN else ""
544 return CheckResult(
545 "policy_labels",
546 _WARN,
547 f"{len(missing)} of {len(declared)} declared label(s) missing on {repo}: "
548 f"{shown}{extra} — create them, or run keel doctor --fix",
549 detail,
550 )
551 return CheckResult(
552 "policy_labels",
553 _OK,
554 f"all {len(declared)} declared label(s) exist on {repo}",
555 detail,
556 )
559def _check_github_cli(facts: dict[str, object] | None) -> CheckResult:
560 """Can a live run reach GitHub — is ``gh`` on PATH and logged in (#1334)?
562 Never a ``fail``: a dry run needs no ``gh`` at all, so a missing or logged-out one
563 is a ``warn`` that says what to do. ``--offline`` leaves the auth question
564 unasked (it goes to GitHub) and reports ``skipped``, as does a ``gh auth status``
565 that timed out: neither looked, so neither may claim a login state.
566 """
567 if facts is None:
568 return CheckResult("github_cli", _SKIPPED, "gh not probed", {})
569 detail = dict(facts)
570 gh = facts.get("gh")
571 if not gh:
572 return CheckResult(
573 "github_cli",
574 _WARN,
575 "gh not found on PATH — a live run (keel ship --live) needs it: install the "
576 "GitHub CLI, then gh auth login",
577 detail,
578 )
579 authenticated = facts.get("authenticated")
580 if authenticated is None:
581 reason = str(facts.get("reason") or "gh auth status not run")
582 return CheckResult("github_cli", _SKIPPED, f"gh at {gh}; {reason}", detail)
583 if not authenticated:
584 # "Failed", not "not logged in": `gh auth status` exits 1 when *any* configured
585 # host has a bad token, and its output says which — so it is quoted, not guessed.
586 return CheckResult(
587 "github_cli",
588 _WARN,
589 f"gh at {gh}: gh auth status failed ({facts.get('reason')}) — fix what it names "
590 "(usually gh auth login) before a live run",
591 detail,
592 )
593 return CheckResult("github_cli", _OK, f"gh at {gh} is authenticated", detail)
596def _check_agent_hosts(facts: dict[str, object] | None) -> CheckResult:
597 """Which agent host CLIs are on PATH (#1334)?
599 A PATH lookup, nothing executed: this answers "is there anything here to drive
600 ``/keel:ship``", cheaply, on every run. Whether a delegate actually answers is
601 ``--providers``' question. None found is a ``warn``, never a ``fail`` — a host
602 that lives only inside an editor is not on PATH, and doctor cannot see it.
603 """
604 if facts is None:
605 return CheckResult("agent_hosts", _SKIPPED, "agent hosts not probed", {})
606 hosts = list(facts.get("hosts") or [])
607 found = [str(h.get("name")) for h in hosts if h.get("path")]
608 missing = [str(h.get("name")) for h in hosts if not h.get("path")]
609 detail: dict[str, object] = {"hosts": hosts, "found": found, "missing": missing}
610 if not found:
611 return CheckResult(
612 "agent_hosts",
613 _WARN,
614 f"no agent host on PATH (looked for {', '.join(missing)}) — a live /keel:ship "
615 "needs one to drive it; keel doctor --providers probes delegates in depth",
616 detail,
617 )
618 return CheckResult(
619 "agent_hosts",
620 _OK,
621 f"{len(found)} of {len(hosts)} agent host(s) on PATH: {', '.join(found)}",
622 detail,
623 )
626def run_doctor(
627 *,
628 installed_version: str,
629 latest_version: str | None,
630 adapter_markers: list[dict[str, object]],
631 orphans: list[dict[str, object]],
632 core_version: str | None,
633 state_paths: list[dict[str, object]],
634 module_path: str | None = None,
635 checkout_root: str | None = None,
636 python_toolchain: dict[str, object] | None = None,
637 policy_labels: dict[str, object] | None = None,
638 providers: dict[str, object] | None = None,
639 github_cli: dict[str, object] | None = None,
640 agent_hosts: dict[str, object] | None = None,
641) -> dict[str, object]:
642 """Run all diagnostic checks over already-gathered facts (pure, deterministic).
644 Returns a JSON-stable dict: ``schema_version``, ``installed_version``, the
645 ordered ``checks`` list, and a roll-up ``status`` (worst of all checks) plus
646 counts. The caller maps ``status`` to an exit code (and ``--strict`` turns a
647 ``fail`` roll-up into a non-zero exit).
648 """
649 checks = [
650 _check_checkout_binding(module_path, checkout_root),
651 _check_cli_version(installed_version, latest_version),
652 _check_adapter_version(installed_version, adapter_markers),
653 _check_orphan_adapters(orphans),
654 _check_core_version(installed_version, core_version),
655 _check_state_paths(state_paths),
656 _check_python_toolchain(python_toolchain),
657 _check_policy_labels(policy_labels),
658 # Appended, not inserted: the existing checks keep their positions (#1334).
659 _check_github_cli(github_cli),
660 _check_agent_hosts(agent_hosts),
661 ]
662 # Only when asked for: the provider probe shells out once per CLI vendor and makes
663 # one loopback request, which the default run must not pay for on every invocation.
664 if providers is not None:
665 checks.append(_check_providers(providers))
666 # A skipped check reports that it could not look; it never speaks for the roll-up.
667 # ``cli_version`` is never ``skipped`` (offline is its ``warn``), so this is never empty.
668 worst = max((c.status for c in checks if c.status != _SKIPPED), key=lambda s: _RANK[s])
669 counts = {_OK: 0, _SKIPPED: 0, _WARN: 0, _FAIL: 0}
670 for check in checks:
671 counts[check.status] += 1
672 report: dict[str, object] = {
673 "schema_version": SCHEMA_VERSION,
674 "installed_version": installed_version,
675 "status": worst,
676 "counts": counts,
677 "checks": [c.as_dict() for c in checks],
678 }
679 if providers is not None:
680 # Merged at the top level rather than nested: the provider document is the
681 # thing `--providers` was asked for, and `{providers, registry_path,
682 # warnings}` is the shape #1011 specifies for it.
683 for key in ("providers", "registry_path", "registry_present", "warnings", "errors"):
684 report[key] = providers.get(key)
685 return report
688def render_report(report: dict[str, object]) -> str:
689 """Render a doctor report as aligned human-readable status lines."""
690 lines = [f"keel doctor — {report['status']} (keel {report['installed_version']})"]
691 for check in report["checks"]:
692 # Four characters keeps the column aligned: OK / WARN / FAIL / SKIP(ped).
693 state = str(check["status"]).upper()[:4]
694 lines.append(f" {state:>4} {check['name']:<16} {check['summary']}")
695 # A check that can name its own fix prints it as a runnable line rather than
696 # burying it in --json, which is the whole point of the policy-label warning.
697 for command in check.get("detail", {}).get("commands") or ():
698 lines.append(f" $ {command}")
699 counts = report["counts"]
700 lines.append(
701 f" summary : {counts[_OK]} ok, {counts[_SKIPPED]} skipped, "
702 f"{counts[_WARN]} warn, {counts[_FAIL]} fail"
703 )
704 return "\n".join(lines)
707#: Compact capability flags in the provider table, in a fixed order.
708_CAPABILITY_FLAGS = (
709 ("tools", "tools"),
710 ("read_only_mode", "read-only"),
711 ("model_selection", "model"),
712)
714#: Models listed per provider row before the rest are summarised as a count.
715_MODELS_SHOWN = 6
718def render_providers(payload: dict[str, object]) -> str:
719 """Render the provider probe as an aligned human table (pure).
721 One row per provider in probe order — built-ins, then project profiles, then the
722 machine-level registry — each naming the transport, where the entry came from, and
723 a reason an operator can act on. Registry warnings and name-clash errors follow the
724 table rather than replacing it: a broken entry must not hide the providers that do
725 work.
726 """
727 rows = list(payload.get("providers") or [])
728 registry = payload.get("registry_path") or "(none)"
729 state = "present" if payload.get("registry_present") else "not present"
730 lines = [
731 f"keel providers — {payload.get('available', 0)} of {payload.get('total', 0)} available",
732 f" registry: {registry} ({state})",
733 ]
734 for row in rows:
735 flags = row.get("capabilities") or {}
736 marks = ",".join(label for key, label in _CAPABILITY_FLAGS if flags.get(key)) or "-"
737 state = "yes" if row.get("available") else "no"
738 lines.append(
739 f" {state:>3} {str(row.get('name')):<18} {str(row.get('transport')):<6} "
740 f"{str(row.get('source')):<8} {marks:<22} {row.get('reason')}"
741 )
742 models = list(row.get("models") or [])
743 if models:
744 shown = ", ".join(models[:_MODELS_SHOWN])
745 extra = f", +{len(models) - _MODELS_SHOWN} more" if len(models) > _MODELS_SHOWN else ""
746 lines.append(f" models: {shown}{extra}")
747 for warning in payload.get("warnings") or []:
748 lines.append(f" warn {warning}")
749 for error in payload.get("errors") or []:
750 lines.append(f" FAIL {error}")
751 return "\n".join(lines)