personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4from __future__ import annotations
5
6import json
7import os
8import subprocess
9import sys
10from pathlib import Path
11from types import SimpleNamespace
12
13import pytest
14
15from solstone.think import install_guard
16from solstone.think.probe import ProbeOutput
17
18ROOT = Path(__file__).resolve().parent.parent
19
20
21@pytest.fixture
22def doctor():
23 from solstone.think import doctor as doctor_module
24
25 yield doctor_module
26
27
28@pytest.fixture
29def home_root(monkeypatch, tmp_path):
30 home = tmp_path / "home"
31 home.mkdir()
32 monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
33 return home
34
35
36def args(doctor, *, port: int = 5015):
37 return doctor.Args(verbose=False, json=False, jsonl=False, port=port)
38
39
40def make_repo(tmp_path: Path, *, worktree: bool = False) -> Path:
41 repo = tmp_path / "repo"
42 repo.mkdir()
43 if worktree:
44 (repo / ".git").write_text("gitdir: /tmp/worktree\n", encoding="utf-8")
45 else:
46 (repo / ".git").mkdir()
47 return repo
48
49
50def ensure_expected_target(repo: Path) -> Path:
51 target = repo / ".venv" / "bin" / "sol"
52 target.parent.mkdir(parents=True, exist_ok=True)
53 target.write_text("", encoding="utf-8")
54 return target
55
56
57def make_alias(home_root: Path, target: Path | str, binary: str = "sol") -> Path:
58 alias = home_root / ".local" / "bin" / binary
59 alias.parent.mkdir(parents=True, exist_ok=True)
60 alias.symlink_to(target)
61 return alias
62
63
64def other_target(tmp_path: Path) -> Path:
65 target = tmp_path / "other" / ".venv" / "bin" / "sol"
66 target.parent.mkdir(parents=True, exist_ok=True)
67 target.write_text("", encoding="utf-8")
68 return target
69
70
71def test_install_guard_import_succeeds_when_frontmatter_is_shadowed(tmp_path):
72 shadow_dir = tmp_path / "shadow"
73 shadow_dir.mkdir()
74 (shadow_dir / "frontmatter.py").write_text(
75 'raise ImportError("blocked for test")\n',
76 encoding="utf-8",
77 )
78 env = os.environ.copy()
79 pythonpath_parts = [str(shadow_dir), str(ROOT)]
80 existing_pythonpath = env.get("PYTHONPATH")
81 if existing_pythonpath:
82 pythonpath_parts.append(existing_pythonpath)
83 env["PYTHONPATH"] = os.pathsep.join(pythonpath_parts)
84
85 result = subprocess.run(
86 [
87 sys.executable,
88 "-c",
89 "from solstone.think.install_guard import parse_wrapper; print('ok')",
90 ],
91 cwd=ROOT,
92 capture_output=True,
93 text=True,
94 check=False,
95 env=env,
96 )
97
98 assert result.returncode == 0
99 assert result.stdout.strip() == "ok"
100
101
102class TestPythonVersion:
103 def test_ok(self, doctor):
104 result = doctor.python_sanity_check(args(doctor))
105 assert result.status == "ok"
106
107 def test_fail_when_too_old(self, doctor, monkeypatch):
108 monkeypatch.setattr(doctor.sys, "version_info", (3, 9, 18))
109 result = doctor.python_sanity_check(args(doctor))
110 assert result.status == "fail"
111 assert "does not satisfy" in result.detail
112
113
114class TestSolImportable:
115 def test_skip_when_absent(self, doctor, monkeypatch, tmp_path):
116 monkeypatch.setattr(doctor, "ROOT", tmp_path)
117 result = doctor.sol_importable_check(args(doctor))
118 assert result.status == "skip"
119
120 def test_ok(self, doctor, monkeypatch, tmp_path):
121 monkeypatch.setattr(doctor, "ROOT", tmp_path)
122 python_bin = tmp_path / ".venv" / "bin" / "python"
123 python_bin.parent.mkdir(parents=True)
124 python_bin.write_text("", encoding="utf-8")
125 monkeypatch.setattr(
126 doctor,
127 "run_probe",
128 lambda *_args, **_kwargs: ProbeOutput("", "", 0),
129 )
130 result = doctor.sol_importable_check(args(doctor))
131 assert result.status == "ok"
132 assert (
133 result.detail
134 == "from solstone.think.sol_compat_cli import main succeeded outside repo cwd"
135 )
136
137 def test_fail_on_module_not_found(self, doctor, monkeypatch, tmp_path):
138 monkeypatch.setattr(doctor, "ROOT", tmp_path)
139 python_bin = tmp_path / ".venv" / "bin" / "python"
140 python_bin.parent.mkdir(parents=True)
141 python_bin.write_text("", encoding="utf-8")
142 monkeypatch.setattr(
143 doctor,
144 "run_probe",
145 lambda *_args, **_kwargs: ProbeOutput(
146 "",
147 "Traceback (most recent call last):\nModuleNotFoundError: No module named 'solstone'\n",
148 1,
149 ),
150 )
151 result = doctor.sol_importable_check(args(doctor))
152 assert result.status == "fail"
153 assert result.detail == "ModuleNotFoundError: No module named 'solstone'"
154
155 def test_fail_on_other_exception(self, doctor, monkeypatch, tmp_path):
156 monkeypatch.setattr(doctor, "ROOT", tmp_path)
157 python_bin = tmp_path / ".venv" / "bin" / "python"
158 python_bin.parent.mkdir(parents=True)
159 python_bin.write_text("", encoding="utf-8")
160 monkeypatch.setattr(
161 doctor,
162 "run_probe",
163 lambda *_args, **_kwargs: ProbeOutput(
164 "", "SyntaxError: broken import\n", 1
165 ),
166 )
167 result = doctor.sol_importable_check(args(doctor))
168 assert result.status == "fail"
169 assert result.detail == "SyntaxError: broken import"
170
171
172class TestPackagedInstall:
173 def setup_packaged(self, doctor, monkeypatch, tmp_path):
174 monkeypatch.setattr(doctor, "ROOT", tmp_path)
175 monkeypatch.setattr(doctor, "is_packaged_install", lambda: True)
176
177 def test_python_version_uses_metadata_when_pyproject_absent(
178 self, doctor, monkeypatch, tmp_path
179 ):
180 self.setup_packaged(doctor, monkeypatch, tmp_path)
181 monkeypatch.setattr(
182 doctor,
183 "distribution",
184 lambda name: SimpleNamespace(metadata={"Requires-Python": ">=3.11"}),
185 )
186
187 result = doctor.python_sanity_check(args(doctor))
188
189 assert result.status == "ok"
190 assert ">=3.11" in result.detail
191
192 def test_sol_importable_uses_in_process_import(self, doctor, monkeypatch, tmp_path):
193 self.setup_packaged(doctor, monkeypatch, tmp_path)
194
195 result = doctor.sol_importable_check(args(doctor))
196
197 assert result.status == "ok"
198 assert result.detail == "import solstone succeeded in packaged install"
199
200
201class TestDefaultSttReady:
202 @staticmethod
203 def setup_linux_parakeet_default(doctor, monkeypatch, tmp_path):
204 journal = tmp_path / "journal"
205 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env"))
206 monkeypatch.setattr(
207 doctor.parakeet_readiness,
208 "_platform_info",
209 lambda: ("linux", "x86_64"),
210 )
211 return journal
212
213 @staticmethod
214 def make_ready_files(doctor, cache_root: Path, artifact_key: str) -> None:
215 for backend in doctor.parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS:
216 path = doctor.parakeet_readiness.parakeet_cpp_binary_path(
217 cache_root, artifact_key, backend
218 )
219 path.parent.mkdir(parents=True, exist_ok=True)
220 path.write_text("#!/bin/sh\n", encoding="utf-8")
221 path.chmod(0o755)
222 model_path = doctor.parakeet_readiness.parakeet_cpp_model_path(cache_root)
223 model_path.parent.mkdir(parents=True, exist_ok=True)
224 model_path.write_text("model", encoding="utf-8")
225
226 def test_ok_when_linux_runtime_and_model_ready(self, doctor, monkeypatch, tmp_path):
227 from solstone.think.providers import parakeet_server
228
229 journal = self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path)
230 artifact_key = "x86_64-unknown-linux-gnu"
231 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal)
232 self.make_ready_files(doctor, cache_root, artifact_key)
233 monkeypatch.setattr(
234 parakeet_server,
235 "probe_state",
236 lambda: (parakeet_server.STATE_READY, None),
237 )
238
239 result = doctor.default_stt_ready_check(args(doctor))
240
241 assert result.status == "ok"
242 assert result.detail == (
243 "parakeet-cpp ready (binaries + model installed, server reachable)"
244 )
245
246 def test_warns_when_linux_cpp_artifacts_missing(
247 self, doctor, monkeypatch, tmp_path
248 ):
249 self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path)
250
251 result = doctor.default_stt_ready_check(args(doctor))
252
253 assert result.status == "warn"
254 assert "missing" in result.detail
255 assert result.fix == doctor._PARAKEET_CPP_INSTALL_FIX
256
257 def test_warns_when_linux_cpp_server_not_started(
258 self, doctor, monkeypatch, tmp_path
259 ):
260 from solstone.think.providers import parakeet_server
261
262 journal = self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path)
263 artifact_key = "x86_64-unknown-linux-gnu"
264 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal)
265 self.make_ready_files(doctor, cache_root, artifact_key)
266 monkeypatch.setattr(
267 parakeet_server,
268 "probe_state",
269 lambda: (parakeet_server.STATE_FAILED, "no port"),
270 )
271
272 result = doctor.default_stt_ready_check(args(doctor))
273
274 assert result.status == "warn"
275 assert result.detail == "parakeet-server not reachable: no port"
276 assert result.fix == doctor._PARAKEET_CPP_START_FIX
277
278 def test_default_stt_fix_strings_are_distinct(self, doctor):
279 assert doctor._PARAKEET_CPP_INSTALL_FIX != doctor._PARAKEET_CPP_START_FIX
280
281 def test_skips_when_configured_backend_is_not_parakeet(
282 self, doctor, monkeypatch, tmp_path
283 ):
284 journal = tmp_path / "journal"
285 config_path = journal / "config" / "journal.json"
286 config_path.parent.mkdir(parents=True)
287 config_path.write_text(
288 json.dumps({"transcribe": {"backend": "confidential"}}),
289 encoding="utf-8",
290 )
291 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
292 monkeypatch.setattr(
293 doctor.parakeet_readiness,
294 "_platform_info",
295 lambda: pytest.fail("platform should not be checked"),
296 )
297
298 result = doctor.default_stt_ready_check(args(doctor))
299
300 assert result.status == "skip"
301 assert "confidential" in result.detail
302
303 def test_journal_less_host_defaults_to_parakeet_without_creating_journal(
304 self, doctor, monkeypatch, tmp_path
305 ):
306 journal = tmp_path / "missing-journal"
307 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
308 monkeypatch.setattr(
309 doctor.parakeet_readiness,
310 "_platform_info",
311 lambda: ("linux", "x86_64"),
312 )
313
314 result = doctor.default_stt_ready_check(args(doctor))
315
316 assert result.status == "warn"
317 assert "missing" in result.detail
318 assert "configured backend" not in result.detail
319 assert not journal.exists()
320
321 def test_skips_arm64_linux_in_runner(self, doctor, monkeypatch, tmp_path):
322 self.setup_linux_parakeet_default(doctor, monkeypatch, tmp_path)
323 monkeypatch.setattr(
324 doctor.parakeet_readiness,
325 "_platform_info",
326 lambda: ("linux", "aarch64"),
327 )
328
329 result = doctor.default_stt_ready_check(args(doctor))
330
331 assert result.status == "skip"
332 assert result.detail == "parakeet not supported on this platform"
333
334 def test_registered_for_journal_and_journal_readiness_only(self, doctor):
335 journal_names = {check.name for check, _runner in doctor.JOURNAL_CHECKS}
336 readiness_names = {check.name for check, _runner in doctor.READINESS_CHECKS}
337 journal_readiness_names = {
338 check.name for check, _runner in doctor.JOURNAL_READINESS_CHECKS
339 }
340 universal_names = {check.name for check, _runner in doctor.UNIVERSAL_CHECKS}
341
342 assert "default_stt_ready" in journal_names
343 assert "default_stt_ready" not in readiness_names
344 assert "default_stt_ready" in journal_readiness_names
345 assert "default_stt_ready" not in universal_names
346 assert doctor.DEFAULT_STT_READY_CHECK in {
347 check for check, _runner in doctor.JOURNAL_CHECKS
348 }
349 assert doctor.DEFAULT_STT_READY_CHECK in {
350 check for check, _runner in doctor.JOURNAL_READINESS_CHECKS
351 }
352 assert doctor.DEFAULT_STT_READY_CHECK not in {
353 check for check, _runner in doctor.READINESS_CHECKS
354 }
355 assert doctor.CHECK_MAP["default_stt_ready"].severity == "advisory"
356
357
358class TestParakeetCppSttReady:
359 @staticmethod
360 def make_ready_files(doctor, cache_root: Path, artifact_key: str) -> None:
361 for backend in doctor.parakeet_readiness.PARAKEET_CPP_BINARY_BACKENDS:
362 path = doctor.parakeet_readiness.parakeet_cpp_binary_path(
363 cache_root, artifact_key, backend
364 )
365 path.parent.mkdir(parents=True, exist_ok=True)
366 path.write_text("#!/bin/sh\n", encoding="utf-8")
367 path.chmod(0o755)
368 model_path = doctor.parakeet_readiness.parakeet_cpp_model_path(cache_root)
369 model_path.parent.mkdir(parents=True, exist_ok=True)
370 model_path.write_text("model", encoding="utf-8")
371
372 def test_registered_for_journal_and_journal_readiness_only(self, doctor):
373 pair = (
374 doctor.PARAKEET_CPP_STT_READY_CHECK,
375 doctor.parakeet_cpp_stt_ready_check,
376 )
377
378 assert doctor.PARAKEET_CPP_STT_READY_CHECK.name in doctor.CHECK_MAP
379 assert pair in doctor.JOURNAL_CHECKS
380 assert pair in doctor.JOURNAL_READINESS_CHECKS
381 assert pair not in doctor.UNIVERSAL_CHECKS
382 assert pair not in doctor.READINESS_CHECKS
383
384 def test_skips_when_backend_is_not_parakeet_cpp(self, doctor, monkeypatch):
385 monkeypatch.setattr(doctor, "_resolve_configured_backend", lambda: "parakeet")
386
387 result = doctor.parakeet_cpp_stt_ready_check(args(doctor))
388
389 assert result.status == "skip"
390 assert (
391 result.detail
392 == "configured backend is not parakeet-cpp; check not applicable"
393 )
394
395 def test_skips_off_linux(self, doctor, monkeypatch):
396 monkeypatch.setattr(
397 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp"
398 )
399 monkeypatch.setattr(
400 doctor.parakeet_readiness,
401 "_platform_info",
402 lambda: ("darwin", "arm64"),
403 )
404
405 result = doctor.parakeet_cpp_stt_ready_check(args(doctor))
406
407 assert result.status == "skip"
408 assert result.detail == "parakeet-cpp is only supported on Linux"
409
410 def test_warns_with_install_fix_when_files_missing(
411 self, doctor, monkeypatch, tmp_path
412 ):
413 journal = tmp_path / "journal"
414 monkeypatch.setattr(
415 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp"
416 )
417 monkeypatch.setattr(
418 doctor.parakeet_readiness,
419 "_platform_info",
420 lambda: ("linux", "x86_64"),
421 )
422 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env"))
423
424 result = doctor.parakeet_cpp_stt_ready_check(args(doctor))
425
426 assert result.status == "warn"
427 assert "missing" in result.detail
428 assert result.fix == doctor._PARAKEET_CPP_INSTALL_FIX
429
430 def test_warns_with_start_fix_when_server_not_ready(
431 self, doctor, monkeypatch, tmp_path
432 ):
433 from solstone.think.providers import parakeet_server
434
435 journal = tmp_path / "journal"
436 artifact_key = "x86_64-unknown-linux-gnu"
437 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal)
438 self.make_ready_files(doctor, cache_root, artifact_key)
439 monkeypatch.setattr(
440 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp"
441 )
442 monkeypatch.setattr(
443 doctor.parakeet_readiness,
444 "_platform_info",
445 lambda: ("linux", "x86_64"),
446 )
447 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env"))
448 monkeypatch.setattr(
449 parakeet_server,
450 "probe_state",
451 lambda: (parakeet_server.STATE_FAILED, "no port"),
452 )
453
454 result = doctor.parakeet_cpp_stt_ready_check(args(doctor))
455
456 assert result.status == "warn"
457 assert result.detail == "parakeet-server not reachable: no port"
458 assert result.fix == doctor._PARAKEET_CPP_START_FIX
459
460 def test_ok_when_files_present_and_server_ready(
461 self, doctor, monkeypatch, tmp_path
462 ):
463 from solstone.think.providers import parakeet_server
464
465 journal = tmp_path / "journal"
466 artifact_key = "x86_64-unknown-linux-gnu"
467 cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal)
468 self.make_ready_files(doctor, cache_root, artifact_key)
469 monkeypatch.setattr(
470 doctor, "_resolve_configured_backend", lambda: "parakeet-cpp"
471 )
472 monkeypatch.setattr(
473 doctor.parakeet_readiness,
474 "_platform_info",
475 lambda: ("linux", "x86_64"),
476 )
477 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env"))
478 monkeypatch.setattr(
479 parakeet_server,
480 "probe_state",
481 lambda: (parakeet_server.STATE_READY, None),
482 )
483
484 result = doctor.parakeet_cpp_stt_ready_check(args(doctor))
485
486 assert result.status == "ok"
487 assert result.detail == (
488 "parakeet-cpp ready (binaries + model installed, server reachable)"
489 )
490
491 def test_does_not_create_absent_journal_when_not_configured(
492 self, doctor, monkeypatch, tmp_path
493 ):
494 journal = tmp_path / "missing-journal"
495 monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env"))
496
497 result = doctor.parakeet_cpp_stt_ready_check(args(doctor))
498
499 assert result.status == "skip"
500 assert not journal.exists()
501
502
503class TestHostDependencies:
504 def test_client_readiness_battery_is_host_free(self, doctor, monkeypatch):
505 expected = [
506 "python_version",
507 "sol_importable",
508 "local_bin_sol_reachable",
509 "stale_alias_symlink",
510 "disk_space",
511 "journal_dir_writable",
512 ]
513 readiness_names = [check.name for check, _runner in doctor.READINESS_CHECKS]
514
515 assert readiness_names == expected
516 for name in [
517 "host_dependencies",
518 "default_stt_ready",
519 "feature:pdf-import",
520 "feature:pdf-export",
521 ]:
522 assert name not in readiness_names
523
524 monkeypatch.setattr(doctor.sys, "argv", ["sol doctor"])
525 assert "host_dependencies" not in {
526 check.name for check, _runner in doctor.select_battery(args(doctor))
527 }
528 assert "host_dependencies" not in {
529 check.name
530 for check, _runner in doctor.select_battery(
531 doctor.parse_args(["--readiness"])
532 )
533 }
534
535 def test_journal_readiness_selects_host_stack_first(self, doctor, monkeypatch):
536 parsed = doctor.parse_args(["--readiness"])
537 monkeypatch.setattr(doctor.sys, "argv", ["journal doctor"])
538
539 selected = doctor.select_battery(parsed)
540
541 assert selected is doctor.JOURNAL_READINESS_CHECKS
542 assert selected[0][0] is doctor.HOST_DEPENDENCIES_CHECK
543 assert selected[0][0].name == "host_dependencies"
544 assert "default_stt_ready" in {check.name for check, _runner in selected}
545 assert "feature:pdf-import" in {check.name for check, _runner in selected}
546 assert "feature:pdf-export" in {check.name for check, _runner in selected}
547
548 def test_host_dependencies_registered_in_journal_and_check_map(self, doctor):
549 assert "host_dependencies" in {
550 check.name for check, _runner in doctor.JOURNAL_CHECKS
551 }
552 assert "host_dependencies" in {
553 check.name for check, _runner in doctor.JOURNAL_READINESS_CHECKS
554 }
555 assert "host_dependencies" not in {
556 check.name for check, _runner in doctor.READINESS_CHECKS
557 }
558 assert doctor.CHECK_MAP["host_dependencies"].severity == "blocker"
559
560 def test_maint_tasks_registered_in_journal_and_check_map(self, doctor):
561 pair = (
562 doctor.JOURNAL_MAINT_TASKS_CHECK,
563 doctor.journal_maint_tasks_check,
564 )
565
566 assert doctor.JOURNAL_MAINT_TASKS_CHECK.name == "journal_maint_tasks"
567 assert doctor.JOURNAL_MAINT_TASKS_CHECK.name in doctor.CHECK_MAP
568 assert doctor.CHECK_MAP["journal_maint_tasks"].severity == "blocker"
569 assert pair in doctor.JOURNAL_CHECKS
570 assert pair not in doctor.UNIVERSAL_CHECKS
571 assert pair not in doctor.READINESS_CHECKS
572 assert pair not in doctor.JOURNAL_READINESS_CHECKS
573
574 def test_host_dependencies_ok_when_present(self, doctor, monkeypatch):
575 monkeypatch.setattr(doctor, "_host_module_present", lambda _module: True)
576
577 result = doctor.host_dependencies_check(args(doctor))
578
579 assert result.status == "ok"
580 assert result.detail == (
581 "journal host dependencies present: python-frontmatter, Flask, ONNX runtime"
582 )
583
584 @pytest.mark.parametrize(
585 ("missing_module", "label"),
586 [
587 ("frontmatter", "python-frontmatter"),
588 ("flask", "Flask"),
589 ("onnxruntime", "ONNX runtime"),
590 ],
591 )
592 def test_host_dependencies_fail_when_find_spec_returns_none(
593 self, doctor, monkeypatch, missing_module, label
594 ):
595 def fake_find_spec(module):
596 return None if module == missing_module else object()
597
598 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec)
599
600 result = doctor.host_dependencies_check(args(doctor))
601
602 assert result.status == "fail"
603 assert result.severity == "blocker"
604 assert label in result.detail
605 assert "journal host stack incomplete" in result.detail
606 assert result.fix == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE
607
608 def test_host_dependencies_fail_when_find_spec_raises(self, doctor, monkeypatch):
609 def fake_find_spec(module):
610 if module == "frontmatter":
611 raise ModuleNotFoundError("No module named 'frontmatter'")
612 return object()
613
614 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec)
615
616 result = doctor.host_dependencies_check(args(doctor))
617
618 assert result.status == "fail"
619 assert "python-frontmatter" in result.detail
620 assert result.fix == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE
621
622
623class TestPackagingChecks:
624 def set_versions(self, doctor, monkeypatch, **overrides):
625 versions = {
626 "solstone": None,
627 "solstone-journal": None,
628 "solstone-journal-cuda": None,
629 "solstone-journal-host": None,
630 }
631 versions.update(overrides)
632 monkeypatch.setattr(doctor, "_installed_packaging_versions", lambda: versions)
633
634 def test_leaf_exclusivity_fails_when_both_leaves_installed(
635 self, doctor, monkeypatch
636 ):
637 self.set_versions(
638 doctor,
639 monkeypatch,
640 **{
641 "solstone-journal": "9.9.9",
642 "solstone-journal-cuda": "9.9.8",
643 },
644 )
645
646 result = doctor.journal_leaf_exclusivity_check(args(doctor))
647
648 assert result.status == "fail"
649 assert result.severity == "blocker"
650 assert "solstone-journal 9.9.9" in result.detail
651 assert "solstone-journal-cuda 9.9.8" in result.detail
652 assert "CPU and CUDA ONNX runtimes own the same files" in result.detail
653
654 @pytest.mark.parametrize(
655 ("leaf_name", "other_leaf"),
656 [
657 ("solstone-journal", "solstone-journal-cuda"),
658 ("solstone-journal-cuda", "solstone-journal"),
659 ],
660 )
661 def test_package_version_fails_when_leaf_mismatches_solstone(
662 self, doctor, monkeypatch, leaf_name, other_leaf
663 ):
664 self.set_versions(
665 doctor,
666 monkeypatch,
667 solstone="9.9.9",
668 **{leaf_name: "9.9.8"},
669 )
670
671 result = doctor.journal_package_version_check(args(doctor))
672
673 assert result.status == "fail"
674 assert result.severity == "blocker"
675 assert leaf_name in result.detail
676 if other_leaf == "solstone-journal":
677 assert "solstone-journal " not in result.detail
678 else:
679 assert other_leaf not in result.detail
680 assert "9.9.9" in result.detail
681 assert "9.9.8" in result.detail
682 assert leaf_name in (result.fix or "")
683
684 def test_retired_host_shim_warns_alongside_leaf(self, doctor, monkeypatch):
685 self.set_versions(
686 doctor,
687 monkeypatch,
688 **{
689 "solstone-journal": "9.9.9",
690 "solstone-journal-host": "0.7.0",
691 },
692 )
693
694 result = doctor.retired_host_shim_check(args(doctor))
695
696 assert result.status == "warn"
697 assert result.severity == "advisory"
698 assert "solstone-journal-host 0.7.0" in result.detail
699 assert "solstone-journal 9.9.9" in result.detail
700 assert result.fix == "pip uninstall solstone-journal-host"
701
702 def test_healthy_cuda_leaf_is_ok(self, doctor, monkeypatch):
703 self.set_versions(
704 doctor,
705 monkeypatch,
706 solstone="9.9.9",
707 **{"solstone-journal-cuda": "9.9.9"},
708 )
709
710 exclusivity = doctor.journal_leaf_exclusivity_check(args(doctor))
711 version = doctor.journal_package_version_check(args(doctor))
712 shim = doctor.retired_host_shim_check(args(doctor))
713
714 assert exclusivity.status == "ok"
715 assert version.status == "ok"
716 assert shim.status == "ok"
717
718 def test_journal_readiness_json_fails_on_missing_host_dependency(
719 self, doctor, monkeypatch, capsys
720 ):
721 def fake_find_spec(module):
722 return None if module == "frontmatter" else object()
723
724 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec)
725 monkeypatch.setattr(doctor.sys, "argv", ["journal doctor"])
726
727 rc = doctor.main(["--readiness", "--json"])
728 payload = json.loads(capsys.readouterr().out)
729 host_check = next(
730 check for check in payload["checks"] if check["name"] == "host_dependencies"
731 )
732
733 assert rc == 1
734 assert host_check["severity"] == "blocker"
735 assert host_check["status"] == "fail"
736 assert "python-frontmatter" in host_check["detail"]
737 assert host_check["fix"] == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE
738
739 def test_journal_readiness_jsonl_fails_on_missing_host_dependency(
740 self, doctor, monkeypatch, capsys
741 ):
742 def fake_find_spec(module):
743 return None if module == "flask" else object()
744
745 monkeypatch.setattr(doctor.importlib.util, "find_spec", fake_find_spec)
746 monkeypatch.setattr(doctor.sys, "argv", ["journal doctor"])
747
748 rc = doctor.main(["--readiness", "--jsonl"])
749 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
750 host_index, host_event = next(
751 (index, event)
752 for index, event in enumerate(events)
753 if event["event"] == "check.completed"
754 and event["name"] == "host_dependencies"
755 )
756 completed_index = next(
757 index
758 for index, event in enumerate(events)
759 if event["event"] == "doctor.completed"
760 )
761
762 assert rc == 1
763 assert host_index < completed_index
764 assert host_event["severity"] == "blocker"
765 assert host_event["status"] == "failed"
766 assert "Flask" in host_event["detail"]
767 assert host_event["fix"] == doctor.HOST_DEPENDENCY_REINSTALL_GUIDANCE
768 assert events[completed_index]["status"] == "failed"
769
770
771class TestPortCheckRemoved:
772 def test_port_check_is_not_registered(self, doctor):
773 assert "port_5015_free" not in doctor.CHECK_MAP
774 assert "port_5015_free" not in {
775 check.name for check, _runner in doctor.UNIVERSAL_CHECKS
776 }
777
778
779def test_default_universal_battery_check_names(doctor):
780 assert {check.name for check, _runner in doctor.UNIVERSAL_CHECKS} == {
781 "python_version",
782 "sol_importable",
783 "journal_leaf_exclusivity",
784 "journal_package_version",
785 "retired_host_shim",
786 "local_bin_sol_reachable",
787 "stale_alias_symlink",
788 "skill_state",
789 }
790
791
792class TestStaleAliasSymlink:
793 @pytest.fixture(autouse=True)
794 def isolated_legacy_backups(self, doctor, monkeypatch, tmp_path):
795 backup_dir = tmp_path / "legacy-backups"
796 backup_dir.mkdir()
797 monkeypatch.setattr(install_guard, "_legacy_backup_dir", lambda: backup_dir)
798 self.backup_dir = backup_dir
799
800 def setup_import(self, doctor, monkeypatch):
801 monkeypatch.setattr(
802 doctor,
803 "import_install_guard",
804 lambda: (install_guard.AliasState, install_guard.check_alias),
805 )
806
807 @staticmethod
808 def make_existing_target(path: Path) -> Path:
809 path.parent.mkdir(parents=True, exist_ok=True)
810 path.write_text("", encoding="utf-8")
811 return path
812
813 @staticmethod
814 def write_executable_script(path: Path, content: str = "#!/bin/sh\n") -> Path:
815 path.parent.mkdir(parents=True, exist_ok=True)
816 path.write_text(content, encoding="utf-8")
817 path.chmod(0o755)
818 return path
819
820 def patch_runtime_bin(self, monkeypatch, tmp_path: Path) -> Path:
821 bin_dir = tmp_path / "runtime" / "bin"
822 for binary in ("python", "sol", "journal"):
823 self.write_executable_script(bin_dir / binary)
824 monkeypatch.setattr(sys, "executable", str(bin_dir / "python"))
825 return bin_dir
826
827 @staticmethod
828 def write_regular_alias(home_root: Path, binary: str, content: str) -> Path:
829 alias = home_root / ".local" / "bin" / binary
830 alias.parent.mkdir(parents=True, exist_ok=True)
831 alias.write_text(content, encoding="utf-8")
832 alias.chmod(0o755)
833 return alias
834
835 @staticmethod
836 def assert_foreign_alias_warning(result, binary: str) -> None:
837 assert result.status == "warn"
838 assert result.detail == f"~/.local/bin/{binary} exists but is not a symlink"
839
840 def run_app_owned_check(
841 self,
842 doctor,
843 monkeypatch,
844 home_root: Path,
845 tmp_path: Path,
846 binary: str,
847 content: str,
848 ):
849 self.setup_import(doctor, monkeypatch)
850 repo = make_repo(tmp_path)
851 self.write_regular_alias(home_root, binary, content)
852 monkeypatch.setattr(doctor, "ROOT", repo)
853 return doctor.stale_alias_symlink_check(args(doctor), binary=binary)
854
855 @staticmethod
856 def legacy_target(
857 home_root: Path, legacy_parts: tuple[str, ...], binary: str
858 ) -> Path:
859 return home_root.joinpath(*legacy_parts) / "bin" / binary
860
861 def assert_report_only_alias(
862 self,
863 result,
864 alias: Path,
865 original_target: str,
866 expected_tag: str,
867 other_binary: str,
868 home_root: Path,
869 ) -> None:
870 assert result.status == "warn"
871 assert expected_tag in result.detail
872 assert result.fix is not None
873 assert "journal setup" in result.fix
874 assert alias.is_symlink()
875 assert os.readlink(alias) == original_target
876 assert list(self.backup_dir.glob("*.old-symlink-*")) == []
877 assert not (home_root / ".local" / "bin" / other_binary).exists()
878
879 @pytest.mark.parametrize("binary", ("sol", "journal"))
880 def test_app_owned_child_launcher_ok(
881 self, doctor, monkeypatch, home_root, tmp_path, binary
882 ):
883 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path)
884 expected = bin_dir / binary
885 foreign = self.write_executable_script(
886 tmp_path / "foreign-runtime" / "bin" / binary
887 )
888 assert expected != foreign
889 assert os.access(expected, os.X_OK)
890 assert os.access(foreign, os.X_OK)
891 content = install_guard.render_app_owned_child_launcher(str(expected), binary)
892
893 result = self.run_app_owned_check(
894 doctor, monkeypatch, home_root, tmp_path, binary, content
895 )
896
897 assert result.status == "ok"
898 assert "is not a symlink" not in result.detail
899 assert result.fix is None
900
901 @pytest.mark.parametrize("binary", ("sol", "journal"))
902 def test_app_owned_child_launcher_marker_only_warns(
903 self, doctor, monkeypatch, home_root, tmp_path, binary
904 ):
905 self.patch_runtime_bin(monkeypatch, tmp_path)
906 content = "#!/bin/sh\n# managed-version: app-owned-child\n"
907
908 result = self.run_app_owned_check(
909 doctor, monkeypatch, home_root, tmp_path, binary, content
910 )
911
912 self.assert_foreign_alias_warning(result, binary)
913
914 @pytest.mark.parametrize("binary", ("sol", "journal"))
915 def test_app_owned_child_launcher_malformed_body_warns(
916 self, doctor, monkeypatch, home_root, tmp_path, binary
917 ):
918 self.patch_runtime_bin(monkeypatch, tmp_path)
919 content = (
920 '#!/bin/sh\n# managed-version: app-owned-child\nexec /not-quoted "$@"\n'
921 )
922
923 result = self.run_app_owned_check(
924 doctor, monkeypatch, home_root, tmp_path, binary, content
925 )
926
927 self.assert_foreign_alias_warning(result, binary)
928
929 @pytest.mark.parametrize("binary", ("sol", "journal"))
930 def test_app_owned_child_launcher_other_binary_warns(
931 self, doctor, monkeypatch, home_root, tmp_path, binary
932 ):
933 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path)
934 other_binary = "journal" if binary == "sol" else "sol"
935 content = install_guard.render_app_owned_child_launcher(
936 str(bin_dir / other_binary),
937 other_binary,
938 )
939
940 result = self.run_app_owned_check(
941 doctor, monkeypatch, home_root, tmp_path, binary, content
942 )
943
944 self.assert_foreign_alias_warning(result, binary)
945
946 @pytest.mark.parametrize("binary", ("sol", "journal"))
947 def test_app_owned_child_launcher_missing_target_warns(
948 self, doctor, monkeypatch, home_root, tmp_path, binary
949 ):
950 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path)
951 expected = bin_dir / binary
952 content = install_guard.render_app_owned_child_launcher(str(expected), binary)
953 expected.unlink()
954
955 result = self.run_app_owned_check(
956 doctor, monkeypatch, home_root, tmp_path, binary, content
957 )
958
959 self.assert_foreign_alias_warning(result, binary)
960
961 @pytest.mark.parametrize("binary", ("sol", "journal"))
962 def test_app_owned_child_launcher_non_executable_target_warns(
963 self, doctor, monkeypatch, home_root, tmp_path, binary
964 ):
965 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path)
966 expected = bin_dir / binary
967 content = install_guard.render_app_owned_child_launcher(str(expected), binary)
968 expected.chmod(0o644)
969
970 result = self.run_app_owned_check(
971 doctor, monkeypatch, home_root, tmp_path, binary, content
972 )
973
974 self.assert_foreign_alias_warning(result, binary)
975
976 @pytest.mark.parametrize("binary", ("sol", "journal"))
977 def test_app_owned_child_launcher_outside_executable_target_warns(
978 self, doctor, monkeypatch, home_root, tmp_path, binary
979 ):
980 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path)
981 expected = bin_dir / binary
982 foreign = self.write_executable_script(
983 tmp_path / "foreign-runtime" / "bin" / binary
984 )
985 assert expected != foreign
986 content = install_guard.render_app_owned_child_launcher(str(foreign), binary)
987
988 result = self.run_app_owned_check(
989 doctor, monkeypatch, home_root, tmp_path, binary, content
990 )
991
992 self.assert_foreign_alias_warning(result, binary)
993
994 @pytest.mark.parametrize("binary", ("sol", "journal"))
995 def test_app_owned_child_launcher_outside_symlink_to_expected_target_warns(
996 self, doctor, monkeypatch, home_root, tmp_path, binary
997 ):
998 bin_dir = self.patch_runtime_bin(monkeypatch, tmp_path)
999 expected = bin_dir / binary
1000 outside = tmp_path / "foreign-runtime" / "bin" / binary
1001 outside.parent.mkdir(parents=True, exist_ok=True)
1002 outside.symlink_to(expected)
1003 assert outside.resolve() == expected
1004 content = install_guard.render_app_owned_child_launcher(str(outside), binary)
1005
1006 result = self.run_app_owned_check(
1007 doctor, monkeypatch, home_root, tmp_path, binary, content
1008 )
1009
1010 self.assert_foreign_alias_warning(result, binary)
1011
1012 def test_absent_ok(self, doctor, monkeypatch, home_root, tmp_path):
1013 self.setup_import(doctor, monkeypatch)
1014 repo = make_repo(tmp_path)
1015 monkeypatch.setattr(doctor, "ROOT", repo)
1016 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1017 assert result.status == "ok"
1018
1019 def test_owned_ok(self, doctor, monkeypatch, home_root, tmp_path):
1020 self.setup_import(doctor, monkeypatch)
1021 repo = make_repo(tmp_path)
1022 make_alias(home_root, ensure_expected_target(repo))
1023 monkeypatch.setattr(doctor, "ROOT", repo)
1024 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1025 assert result.status == "ok"
1026
1027 def test_cross_repo_warns(self, doctor, monkeypatch, home_root, tmp_path):
1028 self.setup_import(doctor, monkeypatch)
1029 repo = make_repo(tmp_path)
1030 make_alias(home_root, other_target(tmp_path))
1031 monkeypatch.setattr(doctor, "ROOT", repo)
1032 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1033 assert result.status == "warn"
1034 assert list(self.backup_dir.glob("sol.old-symlink-*")) == []
1035
1036 def test_dangling_warns(self, doctor, monkeypatch, home_root, tmp_path):
1037 self.setup_import(doctor, monkeypatch)
1038 repo = make_repo(tmp_path)
1039 missing = tmp_path / "missing" / ".venv" / "bin" / "sol"
1040 make_alias(home_root, missing)
1041 monkeypatch.setattr(doctor, "ROOT", repo)
1042 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1043 assert result.status == "warn"
1044 assert (home_root / ".local" / "bin" / "sol").is_symlink()
1045
1046 def test_not_symlink_warns(self, doctor, monkeypatch, home_root, tmp_path):
1047 self.setup_import(doctor, monkeypatch)
1048 repo = make_repo(tmp_path)
1049 alias = home_root / ".local" / "bin" / "sol"
1050 alias.parent.mkdir(parents=True, exist_ok=True)
1051 alias.write_text("not a symlink", encoding="utf-8")
1052 monkeypatch.setattr(doctor, "ROOT", repo)
1053 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1054 assert result.status == "warn"
1055 assert list(self.backup_dir.glob("sol.old-symlink-*")) == []
1056
1057 def test_worktree_skip(self, doctor, monkeypatch, home_root, tmp_path):
1058 self.setup_import(doctor, monkeypatch)
1059 repo = make_repo(tmp_path, worktree=True)
1060 monkeypatch.setattr(doctor, "ROOT", repo)
1061 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1062 assert result.status == "skip"
1063
1064 def test_import_failure_skips(self, doctor, monkeypatch):
1065 monkeypatch.setattr(
1066 doctor,
1067 "import_install_guard",
1068 lambda: (_ for _ in ()).throw(ImportError("boom")),
1069 )
1070 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1071 assert result.status == "skip"
1072 assert "could not import solstone.think.install_guard" in result.detail
1073
1074 @pytest.mark.parametrize(
1075 ("legacy_parts", "expected_tag"),
1076 [
1077 ((".local", "share", "uv", "tools", "solstone"), "uv-tool"),
1078 ((".local", "share", "pipx", "venvs", "solstone"), "pipx-xdg"),
1079 ((".local", "pipx", "venvs", "solstone"), "pipx-legacy"),
1080 ],
1081 )
1082 @pytest.mark.parametrize("binary", ("sol", "journal"))
1083 def test_legacy_alias_reports_only(
1084 self,
1085 doctor,
1086 monkeypatch,
1087 home_root,
1088 tmp_path,
1089 legacy_parts,
1090 expected_tag,
1091 binary,
1092 ):
1093 self.setup_import(doctor, monkeypatch)
1094 repo = make_repo(tmp_path)
1095 target = self.make_existing_target(
1096 self.legacy_target(home_root, legacy_parts, binary)
1097 )
1098 alias = make_alias(home_root, target, binary=binary)
1099 original_target = os.readlink(alias)
1100 monkeypatch.setattr(doctor, "ROOT", repo)
1101
1102 result = doctor.stale_alias_symlink_check(args(doctor), binary=binary)
1103
1104 other_binary = "journal" if binary == "sol" else "sol"
1105 self.assert_report_only_alias(
1106 result,
1107 alias,
1108 original_target,
1109 expected_tag,
1110 other_binary,
1111 home_root,
1112 )
1113
1114 @pytest.mark.parametrize(
1115 ("legacy_parts", "expected_tag"),
1116 [
1117 ((".local", "share", "uv", "tools", "solstone"), "uv-tool"),
1118 ((".local", "share", "pipx", "venvs", "solstone"), "pipx-xdg"),
1119 ],
1120 )
1121 @pytest.mark.parametrize("binary", ("sol", "journal"))
1122 def test_dangling_legacy_alias_reports_only(
1123 self,
1124 doctor,
1125 monkeypatch,
1126 home_root,
1127 tmp_path,
1128 legacy_parts,
1129 expected_tag,
1130 binary,
1131 ):
1132 self.setup_import(doctor, monkeypatch)
1133 repo = make_repo(tmp_path)
1134 target = self.legacy_target(home_root, legacy_parts, binary)
1135 target.parent.mkdir(parents=True, exist_ok=True)
1136 alias = make_alias(home_root, target, binary=binary)
1137 original_target = os.readlink(alias)
1138 monkeypatch.setattr(doctor, "ROOT", repo)
1139
1140 result = doctor.stale_alias_symlink_check(args(doctor), binary=binary)
1141
1142 other_binary = "journal" if binary == "sol" else "sol"
1143 self.assert_report_only_alias(
1144 result,
1145 alias,
1146 original_target,
1147 expected_tag,
1148 other_binary,
1149 home_root,
1150 )
1151
1152 def test_non_legacy_target_warns(self, doctor, monkeypatch, home_root, tmp_path):
1153 self.setup_import(doctor, monkeypatch)
1154 repo = make_repo(tmp_path)
1155 target = self.make_existing_target(
1156 tmp_path / "opt" / "random" / "foo" / "bin" / "sol"
1157 )
1158 make_alias(home_root, target)
1159 monkeypatch.setattr(doctor, "ROOT", repo)
1160
1161 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1162
1163 assert result.status == "warn"
1164 assert list(self.backup_dir.glob("sol.old-symlink-*")) == []
1165
1166 def test_partial_migration_recovery_detail(
1167 self, doctor, monkeypatch, home_root, tmp_path
1168 ):
1169 self.setup_import(doctor, monkeypatch)
1170 repo = make_repo(tmp_path)
1171 backup = self.backup_dir / "sol.old-symlink-20260101000000"
1172 backup.write_text("", encoding="utf-8")
1173 monkeypatch.setattr(doctor, "ROOT", repo)
1174
1175 result = doctor.stale_alias_symlink_check(args(doctor), binary="sol")
1176
1177 assert result.status == "warn"
1178 assert "partial migration detected" in result.detail
1179 assert str(backup) in result.detail
1180
1181 def test_journal_binary_not_owned_warns(
1182 self, doctor, monkeypatch, home_root, tmp_path
1183 ):
1184 self.setup_import(doctor, monkeypatch)
1185 repo = make_repo(tmp_path)
1186 target = tmp_path / "other" / ".venv" / "bin" / "journal"
1187 target.parent.mkdir(parents=True, exist_ok=True)
1188 target.write_text("", encoding="utf-8")
1189 make_alias(home_root, target, binary="journal")
1190 monkeypatch.setattr(doctor, "ROOT", repo)
1191
1192 result = doctor.stale_alias_symlink_check(args(doctor), binary="journal")
1193
1194 assert result.status == "warn"
1195
1196 def test_journal_battery_warn_does_not_fail_exit(
1197 self, doctor, monkeypatch, home_root, tmp_path, capsys
1198 ):
1199 self.setup_import(doctor, monkeypatch)
1200 repo = make_repo(tmp_path)
1201 target = tmp_path / "other" / ".venv" / "bin" / "journal"
1202 target.parent.mkdir(parents=True, exist_ok=True)
1203 target.write_text("", encoding="utf-8")
1204 make_alias(home_root, target, binary="journal")
1205 monkeypatch.setattr(doctor, "ROOT", repo)
1206
1207 def journal_stale_check(check_args):
1208 return doctor.stale_alias_symlink_check(check_args, binary="journal")
1209
1210 monkeypatch.setattr(
1211 doctor,
1212 "select_battery",
1213 lambda _args: [(doctor.STALE_ALIAS_CHECK, journal_stale_check)],
1214 )
1215
1216 assert doctor.main([]) == 0
1217 assert "1 warnings" in capsys.readouterr().out
1218
1219
1220class TestJsonAndExitCodes:
1221 def test_json_output(self, doctor, monkeypatch, capsys):
1222 monkeypatch.setattr(
1223 doctor,
1224 "run_checks",
1225 lambda _args: [
1226 doctor.CheckResult("a", "blocker", "ok", "fine", None),
1227 doctor.CheckResult("b", "advisory", "warn", "careful", "fix me"),
1228 ],
1229 )
1230 rc = doctor.main(["--json"])
1231 payload = json.loads(capsys.readouterr().out)
1232 assert rc == 0
1233 assert sorted(payload) == ["checks", "summary"]
1234 assert set(payload["checks"][0]) == {
1235 "name",
1236 "severity",
1237 "status",
1238 "detail",
1239 "fix",
1240 }
1241
1242 def test_exit_code_matrix(self, doctor, monkeypatch, capsys):
1243 monkeypatch.setattr(
1244 doctor,
1245 "run_checks",
1246 lambda _args: [doctor.CheckResult("a", "blocker", "fail", "boom", None)],
1247 )
1248 assert doctor.main([]) == 1
1249 capsys.readouterr()
1250
1251 monkeypatch.setattr(
1252 doctor,
1253 "run_checks",
1254 lambda _args: [doctor.CheckResult("a", "advisory", "fail", "boom", None)],
1255 )
1256 assert doctor.main([]) == 0
1257 capsys.readouterr()
1258
1259 monkeypatch.setattr(
1260 doctor,
1261 "run_checks",
1262 lambda _args: [doctor.CheckResult("a", "blocker", "skip", "skip", None)],
1263 )
1264 assert doctor.main([]) == 0
1265
1266 def test_summary_line_format(self, doctor, monkeypatch, capsys):
1267 monkeypatch.setattr(
1268 doctor,
1269 "run_checks",
1270 lambda _args: [
1271 doctor.CheckResult("a", "blocker", "fail", "boom", None),
1272 doctor.CheckResult("b", "advisory", "warn", "warn", None),
1273 doctor.CheckResult("c", "blocker", "skip", "skip", None),
1274 ],
1275 )
1276 doctor.main([])
1277 output = capsys.readouterr().out.strip().splitlines()
1278 assert output[-1] == "doctor: 3 checks, 1 failed, 1 warnings, 1 skipped"
1279
1280 def test_doctor_jsonl_emits_started_and_completed(
1281 self, doctor, monkeypatch, capsys
1282 ):
1283 monkeypatch.setattr(
1284 doctor,
1285 "run_checks",
1286 lambda _args: [doctor.CheckResult("a", "blocker", "ok", "fine", None)],
1287 )
1288
1289 rc = doctor.main(["--jsonl"])
1290 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
1291
1292 assert rc == 0
1293 assert events[0]["event"] == "doctor.started"
1294 assert events[-1]["event"] == "doctor.completed"
1295 assert events[-1]["status"] == "ok"
1296
1297 def test_doctor_jsonl_emits_check_completed_per_check(
1298 self, doctor, monkeypatch, capsys
1299 ):
1300 monkeypatch.setattr(
1301 doctor,
1302 "run_checks",
1303 lambda _args: [
1304 doctor.CheckResult(check.name, check.severity, "ok", "fine", None)
1305 for check, _func in doctor.UNIVERSAL_CHECKS
1306 ],
1307 )
1308
1309 doctor.main(["--jsonl"])
1310 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
1311
1312 checks = [event for event in events if event["event"] == "check.completed"]
1313 assert len(checks) == len(doctor.UNIVERSAL_CHECKS)
1314
1315 def test_doctor_jsonl_status_translates_short_to_long(
1316 self, doctor, monkeypatch, capsys
1317 ):
1318 monkeypatch.setattr(
1319 doctor,
1320 "run_checks",
1321 lambda _args: [
1322 doctor.CheckResult("ok", "blocker", "ok", "ok", None),
1323 doctor.CheckResult("warn", "advisory", "warn", "warn", None),
1324 doctor.CheckResult("fail", "advisory", "fail", "fail", None),
1325 doctor.CheckResult("skip", "blocker", "skip", "skip", None),
1326 ],
1327 )
1328
1329 rc = doctor.main(["--jsonl"])
1330 events = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
1331
1332 assert rc == 0
1333 statuses = {
1334 event["name"]: event["status"]
1335 for event in events
1336 if event["event"] == "check.completed"
1337 }
1338 assert statuses == {
1339 "ok": "ok",
1340 "warn": "warning",
1341 "fail": "failed",
1342 "skip": "skipped",
1343 }
1344 assert events[-1]["status"] == "warning"
1345
1346 def test_doctor_jsonl_json_and_jsonl_mutually_exclusive(self, doctor):
1347 with pytest.raises(SystemExit) as raised:
1348 doctor.main(["--json", "--jsonl"])
1349
1350 assert raised.value.code == 2
1351
1352 def test_doctor_jsonl_preserves_json_payload_unchanged(
1353 self, doctor, monkeypatch, capsys
1354 ):
1355 monkeypatch.setattr(
1356 doctor,
1357 "run_checks",
1358 lambda _args: [
1359 doctor.CheckResult("a", "advisory", "warn", "careful", None)
1360 ],
1361 )
1362
1363 rc = doctor.main(["--json"])
1364 payload = json.loads(capsys.readouterr().out)
1365
1366 assert rc == 0
1367 assert payload["checks"][0]["status"] == "warn"
1368
1369 def test_doctor_jsonl_subprocess_e2e(self):
1370 result = subprocess.run(
1371 [sys.executable, "-m", "solstone.think.sol_cli", "doctor", "--jsonl"],
1372 capture_output=True,
1373 text=True,
1374 cwd=ROOT,
1375 timeout=60,
1376 )
1377
1378 assert result.returncode in (
1379 0,
1380 1,
1381 ), f"unexpected exit code {result.returncode}: {result.stderr}"
1382 lines = [line for line in result.stdout.splitlines() if line.strip()]
1383 assert lines
1384 for line in lines:
1385 json.loads(line)
1386
1387
1388def test_doctor_import_does_not_pull_numpy_or_observe_layers():
1389 snippet = (
1390 "import sys\n"
1391 "import solstone.think.doctor\n"
1392 "for m in sorted(sys.modules):\n"
1393 " if (\n"
1394 " m == 'numpy'\n"
1395 " or m.startswith('numpy.')\n"
1396 " or m == 'solstone.observe'\n"
1397 " or m.startswith('solstone.observe.')\n"
1398 " ):\n"
1399 " print('LEAKED:' + m)\n"
1400 " sys.exit(3)\n"
1401 "print('OK')\n"
1402 )
1403 result = subprocess.run(
1404 [sys.executable, "-c", snippet],
1405 capture_output=True,
1406 text=True,
1407 cwd=ROOT,
1408 env={
1409 **os.environ,
1410 "SOLSTONE_JOURNAL": str(ROOT / "tests" / "fixtures" / "journal"),
1411 },
1412 timeout=60,
1413 )
1414
1415 assert result.returncode == 0, result.stdout + result.stderr
1416 assert "LEAKED" not in result.stdout
1417
1418
1419def test_readiness_battery_does_not_import_inference_or_installer_layers():
1420 # Proves doctor's readiness battery does not import inference/installer layers.
1421 snippet = (
1422 "import sys\n"
1423 "from solstone.think import doctor\n"
1424 "doctor.run_checks(doctor.parse_args(['--readiness']))\n"
1425 "bad = sorted(\n"
1426 " m\n"
1427 " for m in sys.modules\n"
1428 " if m == 'solstone.think.install_models'\n"
1429 " or m == 'solstone.observe.transcribe'\n"
1430 " or m.startswith('solstone.observe.transcribe.')\n"
1431 ")\n"
1432 "if bad:\n"
1433 " print('LEAKED:' + ','.join(bad))\n"
1434 " sys.exit(3)\n"
1435 "print('OK')\n"
1436 )
1437 result = subprocess.run(
1438 [sys.executable, "-c", snippet],
1439 capture_output=True,
1440 text=True,
1441 cwd=ROOT,
1442 env={
1443 **os.environ,
1444 "SOLSTONE_JOURNAL": str(ROOT / "tests" / "fixtures" / "journal"),
1445 },
1446 timeout=60,
1447 )
1448
1449 assert result.returncode == 0, result.stdout + result.stderr
1450 assert "LEAKED" not in result.stdout
1451
1452
1453def test_journal_readiness_battery_does_not_import_host_or_inference_modules():
1454 snippet = (
1455 "import sys\n"
1456 "sys.argv = ['journal doctor']\n"
1457 "from solstone.think import doctor\n"
1458 "doctor.run_checks(doctor.parse_args(['--readiness']))\n"
1459 "bad = sorted(\n"
1460 " m\n"
1461 " for m in sys.modules\n"
1462 " if m in {'frontmatter', 'flask', 'onnxruntime', "
1463 "'solstone.think.install_models', 'solstone.observe.transcribe'}\n"
1464 " or m.startswith('solstone.observe.transcribe.')\n"
1465 ")\n"
1466 "if bad:\n"
1467 " print('LEAKED:' + ','.join(bad))\n"
1468 " sys.exit(3)\n"
1469 "print('OK')\n"
1470 )
1471 result = subprocess.run(
1472 [sys.executable, "-c", snippet],
1473 capture_output=True,
1474 text=True,
1475 cwd=ROOT,
1476 env={
1477 **os.environ,
1478 "SOLSTONE_JOURNAL": str(ROOT / "tests" / "fixtures" / "journal"),
1479 },
1480 timeout=60,
1481 )
1482
1483 assert result.returncode == 0, result.stdout + result.stderr
1484 assert "LEAKED" not in result.stdout
1485
1486
1487def test_journal_doctor_readiness_subprocess_json_shape():
1488 """End-to-end: journal readiness doctor produces valid diagnostic JSON."""
1489 repo_root = Path(__file__).resolve().parent.parent
1490 result = subprocess.run(
1491 [
1492 sys.executable,
1493 "-m",
1494 "solstone.think.sol_cli",
1495 "doctor",
1496 "--readiness",
1497 "--json",
1498 ],
1499 capture_output=True,
1500 text=True,
1501 cwd=repo_root,
1502 timeout=60,
1503 )
1504 # Exit code: 0 if all checks pass, 1 if any blocker fails. Either is valid
1505 # here; this test asserts CLI routing and payload shape, not machine health.
1506 assert result.returncode in (
1507 0,
1508 1,
1509 ), f"unexpected exit code {result.returncode}: {result.stderr}"
1510 payload = json.loads(result.stdout)
1511 assert "checks" in payload and isinstance(payload["checks"], list)
1512 assert "summary" in payload and isinstance(payload["summary"], dict)
1513 from solstone.think import doctor as doctor_module
1514
1515 assert {check["name"] for check in payload["checks"]} == {
1516 check.name for check, _runner in doctor_module.JOURNAL_READINESS_CHECKS
1517 }
1518
1519
1520def test_doctor_runs_with_minimal_path_env(tmp_path):
1521 """Doctor must complete with PATH=/usr/bin:/bin (launchd-style minimal env)."""
1522 journal = tmp_path / "journal"
1523 journal.mkdir()
1524 env = {
1525 "PATH": "/usr/bin:/bin",
1526 "HOME": str(tmp_path),
1527 "SOLSTONE_JOURNAL": str(journal),
1528 }
1529 result = subprocess.run(
1530 [
1531 sys.executable,
1532 "-m",
1533 "solstone.think.sol_cli",
1534 "doctor",
1535 "--readiness",
1536 "--json",
1537 ],
1538 env=env,
1539 capture_output=True,
1540 text=True,
1541 timeout=60,
1542 )
1543 assert result.returncode in {0, 1}, (
1544 f"doctor crashed: rc={result.returncode}\n"
1545 f"stdout={result.stdout}\nstderr={result.stderr}"
1546 )
1547 payload = json.loads(result.stdout)
1548 from solstone.think import doctor as doctor_module
1549
1550 names = {check["name"] for check in payload["checks"]}
1551 assert names == {
1552 check.name for check, _runner in doctor_module.JOURNAL_READINESS_CHECKS
1553 }
1554 assert not any(
1555 name.startswith("service_") or name == "journal_sync" for name in names
1556 )