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

1"""Pure diagnostics for ``keel doctor`` — a read-only health pass. 

2 

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. 

9 

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""" 

34 

35from __future__ import annotations 

36 

37import re 

38from collections.abc import Iterable 

39from dataclasses import dataclass, field 

40from pathlib import PurePath 

41 

42SCHEMA_VERSION = "keel.doctor.v1" 

43 

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} 

53 

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") 

58 

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+)*)$") 

66 

67 

68@dataclass(frozen=True) 

69class CheckResult: 

70 """One diagnostic check outcome (JSON-stable via :meth:`as_dict`).""" 

71 

72 name: str 

73 status: str 

74 summary: str 

75 detail: dict[str, object] = field(default_factory=dict) 

76 

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 } 

84 

85 

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(".")) 

91 

92 

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)) 

97 

98 

99def constraint_satisfied(installed: str, constraint: str) -> bool | None: 

100 """Does ``installed`` satisfy the ``core_version`` ``constraint``? 

101 

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 

134 

135 

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 ) 

175 

176 

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 ) 

211 

212 

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 ) 

228 

229 

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 ) 

262 

263 

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 ) 

292 

293 

294def _check_python_toolchain(toolchain: dict[str, object] | None) -> CheckResult: 

295 """Will the build gate's interpreter satisfy ``requires-python`` + PyYAML? 

296 

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 ) 

357 

358 

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 

363 

364 

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? 

367 

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. 

372 

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 ) 

406 

407 

408def _check_providers(payload: dict[str, object]) -> CheckResult: 

409 """Classify an already-probed provider report (#1011). 

410 

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. 

413 

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) 

444 

445 

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()] 

451 

452 

453def _qualify(group: str, name: str) -> str: 

454 """Qualify a bare vocabulary entry with its group: ``role`` + ``core`` -> ``role:core``. 

455 

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}" 

461 

462 

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. 

469 

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)) 

490 

491 

492def missing_labels(declared: Iterable[str], existing: Iterable[str]) -> tuple[str, ...]: 

493 """The declared labels that do not exist on the repository. 

494 

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})) 

501 

502 

503#: Missing labels named in the check summary before the rest are counted. 

504_LABELS_SHOWN = 5 

505 

506 

507def _check_policy_labels(payload: dict[str, object] | None) -> CheckResult: 

508 """Do the labels this project declares actually exist on its repository (#1021)? 

509 

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. 

515 

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 ) 

557 

558 

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)? 

561 

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) 

594 

595 

596def _check_agent_hosts(facts: dict[str, object] | None) -> CheckResult: 

597 """Which agent host CLIs are on PATH (#1334)? 

598 

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 ) 

624 

625 

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). 

643 

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 

686 

687 

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) 

705 

706 

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) 

713 

714#: Models listed per provider row before the rest are summarised as a count. 

715_MODELS_SHOWN = 6 

716 

717 

718def render_providers(payload: dict[str, object]) -> str: 

719 """Render the provider probe as an aligned human table (pure). 

720 

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)