tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4767 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan doctor` -- is this host actually able to build and flash?
3
+
4
+ Every check here answers a question some customer already lost an afternoon to.
5
+ Two of them exist because the answer used to be a confident, wrong "Pass".
6
+
7
+ **The Python floor is not what the manifest says it is.**
8
+ `metadata/bootstrap.json` declares `prerequisites.pythonMinVersion` (read live
9
+ below, currently `"3.10"` on alp-sdk's `dev`), while separately
10
+ Zephyr's `cmake/modules/python.cmake` sets `PYTHON_MINIMUM_REQUIRED 3.12`. And
11
+ the Rust oracle's POSIX bootstrap branch was explicit that it "cannot fail on
12
+ version" (`crates/tan-cli/src/commands/bootstrap/steps.rs:230-234`). Ubuntu 22.04
13
+ ships `python3` = 3.10. Compose the three and a fresh customer got: `tan
14
+ bootstrap` succeeds, `tan doctor` reports Pass, and the FIRST build dies inside
15
+ Zephyr's CMake configure with an error naming Zephyr, not us. So the floor this
16
+ command enforces is the EFFECTIVE one -- the higher of the manifest's and
17
+ Zephyr's -- and where the two disagree that disagreement is itself reported
18
+ (`pythonFloor`), naming which is which, so the fix lands in the manifest instead
19
+ of in the customer.
20
+
21
+ **"Zephyr's" used to mean whatever `$ZEPHYR_BASE` pointed at, not the
22
+ workspace the report was actually about (tan-cli#301).** `zephyrWorkspace`
23
+ (tan-cli#290) reads the RESOLVED west topdir (`west_workspace_dir`); until now
24
+ `hostPython`/`pythonFloor` independently re-read `$ZEPHYR_BASE`, which is
25
+ extremely commonly stale -- Zephyr's own docs, and this command's own
26
+ `tan bootstrap` next-steps block, both tell a customer to export it. One
27
+ report could then name two different Zephyrs: `zephyrWorkspace` passing
28
+ against the real workspace while `hostPython`'s floor, and the interpreter it
29
+ demanded, came from an unrelated tree the customer was not building against.
30
+ `_collect` now feeds `zephyr_python_floor` the SAME resolved `workspace_path`
31
+ `zephyrWorkspace` reports, falling back to a literal `$ZEPHYR_BASE` read only
32
+ when no workspace resolves at all, and to `ZEPHYR_PYTHON_FLOOR` when neither
33
+ does -- see `zephyr_python_floor`'s docstring for the three-way split.
34
+
35
+ `tan bootstrap` now enforces the same effective floor on BOTH platforms, by
36
+ calling `zephyr_python_floor` below rather than re-deriving it -- see
37
+ `tan.commands.bootstrap_cmd.resolve_python_floor`. Keep that the ONE reader: a
38
+ second floor rule is how the two commands come to disagree about the same host,
39
+ which is worse than either verdict alone.
40
+
41
+ **SETOOLS was never mentioned by any doctor.** Neither `alp doctor`
42
+ (`scripts/alp_cli/doctor.py` -- it has `_check_python`, `_check_west`,
43
+ `_check_jlink`, and nothing for this) nor the shipped `tan doctor` says a word
44
+ about `SETOOLS_DIR` or `SE_UART`. A customer therefore gets a clean bill of
45
+ health and then meets a bare `RuntimeError` out of
46
+ `scripts/west_commands/runners/alif_flash.py` at the moment they try to flash an
47
+ AEN part. The `setools` check names both, plus the Alif developer download
48
+ (`app-release-exec-linux-SE_FW_x.y.z`) it cannot redistribute.
49
+
50
+ **This check used to ALSO gate on `fdt` being importable under some Python
51
+ interpreter, and that was a false negative even after tan-cli#488 defect 6
52
+ picked the "right" interpreter to ask (tan-cli#641).** Measured on real AEN
53
+ silicon, on a host where Flow D had just succeeded three times in the same
54
+ session: `fdt` genuinely was not importable in the workspace venv, yet
55
+ `app-gen-toc` ran fine, produced
56
+ `app-package-map.txt`, and the MRAM write it fed survived a cold power cycle.
57
+ Whatever `alif_flash.py`'s own `fdt` need is for the SE-UART `west flash`
58
+ path, this check's own wording bundled it into one verdict alongside
59
+ `app-gen-toc` -- which `tan.core.setools.sign_slot0` spawns as its own
60
+ subprocess (`./app-gen-toc -f build/config/...`), never as a Python import
61
+ under any interpreter this command could probe -- and SETOOLS ships whatever
62
+ dependencies IT needs regardless. Dropped rather than reworded again: probing
63
+ one interpreter's `sys.path` was never going to answer "will a flash that
64
+ spawns a separate, license-gated binary succeed," and the false alarm this
65
+ produced trains an operator to stop trusting doctor on the one question they
66
+ ask it before a write.
67
+
68
+ **Nothing that probes may throw.** Four Criticals in this port were uncaught
69
+ exceptions escaping the error contract: a raw traceback instead of an envelope,
70
+ so the VS Code extension renders nothing at all and neither side reports an
71
+ error. `doctor` interrogates a hostile environment BY DEFINITION -- a missing
72
+ binary, an unreadable directory, a tool that waits for a probe that is not
73
+ plugged in, a subprocess that answers in bytes that are not UTF-8. Every one of
74
+ those becomes a structured issue here; `probe()` is the single choke point and
75
+ it has a timeout on every call.
76
+
77
+ **Exit 4, never 0, when unhealthy.** A doctor that exits 0 on a broken
78
+ environment is worse than no doctor: it converts a fixable setup problem into a
79
+ mystery inside somebody else's build system.
80
+
81
+ Deliberately NOT ported from `crates/tan-cli/src/commands/doctor.rs`: the debug
82
+ half (`--target-kind`/`--server`, the cortex-debug/CodeLLDB extension set).
83
+ That needs context this port has no command to produce yet, and half a debug
84
+ verdict is worse than none. The envelope keys that survive --
85
+ `data.summary.{pass,warn,fail}` and `data.checks[]` -- are the ones
86
+ `alp-sdk-vscode` actually reads (`src/debug.ts`, `src/toolchain.ts`).
87
+
88
+ **`--build` is accepted, real, and now (tan-cli#290) a no-op vs. plain `tan
89
+ doctor` -- not the Rust oracle's second, disjoint check vocabulary.**
90
+ Measured against a real `tan.exe`, plain `tan doctor` and `tan doctor
91
+ --build` run two almost entirely different check lists (debug-readiness vs.
92
+ zephyr/yocto/baremetal build-readiness -- compare `tan doctor`'s
93
+ `workspaceRoot`/`codeLLDBExtension`/`lldb` against `tan doctor --build`'s
94
+ `git`/`cmake`/`ninja`/`dtc`/`gperf`/`vendorToolchain`/...). Byte-parity with
95
+ BOTH of those lists is a second command's worth of new checks, not a flag
96
+ gap -- and this port's own check list -- `hostPython`/`hostPrerequisites`/
97
+ `west`/`zephyrSdk`/`setools`/`jlink`, plus (tan-cli#294) `sdk`/`boardYaml`/
98
+ `workspace`/`zephyrVersion`/`zephyrSdkAvailableForHost`/`longPaths`/
99
+ `homePath`/`sdkProvenance`, plus (tan-cli#290) `westResolved`/
100
+ `zephyrWorkspace` -- is ALREADY build/flash-oriented by design (see above),
101
+ unlike the Rust oracle's PLAIN `doctor`. `zephyrWorkspace` -- whether the
102
+ RESOLVED workspace's Zephyr matches alp-sdk's `west.yml` pin -- used to be
103
+ the ONE check this flag gated; ADR 0021 Lane 1 P0a runs PLAIN `tan doctor`
104
+ as the very first command a customer types, before `--build` is ever named,
105
+ so gating it there left the exact alp-sdk#855 v4.4.0->v4.4.1 drift invisible
106
+ on that first run. It is unconditional now, alongside every other
107
+ tan-cli#294/#290 fact -- `--build` therefore changes nothing about this
108
+ port's check-name set any more. The flag stays accepted rather than
109
+ removed: both `alp-sdk-vscode` call sites (`["doctor", "--build"]`,
110
+ `["doctor", "--build", "--fix"]`) still pass it, and a flag a caller already
111
+ relies on does not need to keep doing something to still be worth accepting
112
+ without error.
113
+
114
+ **One name out of that unported `--build` vocabulary came BACK, deliberately
115
+ and partially (tan-cli#1192): `dtc`, and only as `devicetreeLint`.** The
116
+ sentence above still holds for `git`/`cmake`/`ninja`/`gperf`/`vendorToolchain`
117
+ -- none of those is ported and none is planned -- but `dtc`'s premise changed
118
+ underneath it. When this paragraph was written the Zephyr SDK put a `dtc` in
119
+ CMake's reach on every install, so probing for one could only restate what
120
+ `zephyrSdk` already said. Since tan-cli#1176/#1178 `tan bootstrap` acquires
121
+ the SDK with `--no-hosttools`, the SDK ships no `dtc`, and Zephyr's
122
+ devicetree LINT pass (`dts.cmake`'s `if(DTC)` block) silently stopped running
123
+ with nothing on either side saying so. `devicetreeLint` is not the oracle's
124
+ `dtc` check re-ported -- that one was a bare is-it-on-PATH presence probe,
125
+ which after #1178 would warn on every correct install -- it is the narrower
126
+ question that probe cannot answer; see `devicetree_lint_check` for the
127
+ predicate and the severity argument.
128
+
129
+ `--fix` is a separate, NOT-yet-ported flag gap (it is not part of this one):
130
+ the oracle's `--build --fix` auto-repairs a missing Zephyr workspace by
131
+ running `tan bootstrap`, and nothing here does that yet.
132
+ """
133
+ import json
134
+ import os
135
+ import platform
136
+ import re
137
+ import shlex
138
+ import subprocess
139
+ import sys
140
+ import tempfile
141
+ from collections.abc import Callable, Iterable
142
+ from dataclasses import dataclass, field
143
+ from pathlib import Path
144
+
145
+ import typer
146
+
147
+ from tan.commands.build.toolchain import _is_toolchain_wreckage, _toolchain_store_scan_root
148
+ from tan.commands.flash_cmd import _tool_available
149
+ from tan.commands.sdk_cmd import (
150
+ NO_SDK_NEXT_STEPS,
151
+ global_default_pointer_fix_hint,
152
+ parse_sdk_version_yaml,
153
+ )
154
+ from tan.core import artifact_provenance
155
+ from tan.core.artifact_provenance import ArtifactProvenance
156
+ from tan.core.bootstrap import (
157
+ BOOTSTRAP_MANIFEST_SCHEMA_VERSION,
158
+ MissingPrerequisite,
159
+ PrereqFailure,
160
+ WorkspaceSdkRecord,
161
+ confirm_missing,
162
+ detect_linux_pm,
163
+ normalize_linux_install,
164
+ parse_west_zephyr_pin,
165
+ parse_workspace_sdk_record,
166
+ parse_zephyr_version_file,
167
+ posix_venv_unusable,
168
+ reported_missing,
169
+ select_linux_install,
170
+ )
171
+ from tan.core.consent import can_prompt
172
+ from tan.core import devicetree_lint
173
+ from tan.core.doctor_git import (
174
+ _git_behind_upstream,
175
+ _git_core_longpaths,
176
+ _git_short_commit,
177
+ _is_own_git_checkout,
178
+ _resolve_git_executable,
179
+ classify_git_core_longpaths,
180
+ )
181
+ from tan.core.doctor_stale import StaleVerdict, running_tan_verdict
182
+ from tan.core.doctor_libraries import LibraryReport, inspect_selection
183
+ from tan.core.doctor_render import render_check_lines, render_doctor_footer
184
+ from tan.core.doctor_scope import CHECK_SCOPES
185
+ from tan.core.doctor_setools import (
186
+ FLOW_A_METHOD,
187
+ FLOW_D_METHOD,
188
+ project_flash,
189
+ )
190
+ from tan.core.doctor_setools import verdict as setools_verdict
191
+ from tan.core.global_flags import accept_global_flags
192
+ from tan.core.inert import COMPATIBILITY, inert_help
193
+ from tan.core.python_floor import (
194
+ FALLBACK_PYTHON_FLOOR,
195
+ ZEPHYR_PYTHON_FLOOR,
196
+ effective_python_floor,
197
+ zephyr_python_floor,
198
+ )
199
+ from tan.core.host_python import (
200
+ describe_unsuitable,
201
+ probe_all_host_pythons,
202
+ select_host_python,
203
+ )
204
+ from tan.core.probe import PROBE_TIMEOUT_S, probe, probe_status
205
+ from tan.core.subprocess_env import spawn_env
206
+ from tan.core.sdk_default_registry import registry_path
207
+ from tan.core.sdk_discovery import (
208
+ SDK_DISCOVERY_DIVERGENT,
209
+ _abs_posix,
210
+ _has_loader_script,
211
+ _home_alp_dir,
212
+ _pointer_target,
213
+ discover_sdk_root,
214
+ global_default_foreign_project_issue,
215
+ project_pin_issue,
216
+ resolve_sdk_root_ladder,
217
+ resolve_sdk_root_wide,
218
+ )
219
+ from tan.core.shapes import is_sdk_root, rejected_sdk_root_message
220
+ from tan.core.timestamp import generated_at_iso
221
+ from tan.core import toolchain_provision
222
+ from tan.core.tool_lookup import resolve_tool
223
+ from tan.commands.workspace_patch_check import (
224
+ APPLIED as WORKSPACE_PATCHES_APPLIED,
225
+ MISSING as WORKSPACE_PATCHES_MISSING,
226
+ PatchCheck,
227
+ check_workspace_patches,
228
+ )
229
+ from tan.core.west_patches import describe_unapplied, patch_fix_text, zephyr_base_note
230
+ from tan.core.venv import find_workspace_venv, venv_bin_dir, west_program, west_workspace_dir
231
+ from tan.core.west_workspace_refusal import unresolved_workspace_verdict
232
+ from tan.env import TEXT_WRAP_MIN_WIDTH, stderr_is_tty, stdin_is_tty, terminal_width, use_color
233
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
234
+ from tan.exit_codes import ExitCode
235
+ from tan.output_format import FORMAT_HELP, OutputFormat
236
+
237
+ #: The SETOOLS executables `alif_flash.py` looks for inside `$SETOOLS_DIR`
238
+ #: (its `--app-gen-toc` / `--app-write-mram` defaults).
239
+ SETOOLS_EXECUTABLES = ("app-gen-toc", "app-write-mram")
240
+
241
+ #: The Alif developer-portal bundle `$SETOOLS_DIR` must point INTO. The `-linux`
242
+ #: is not incidental: `alif_flash.py` hard-codes `app-release-exec-linux` in the
243
+ #: refusal it raises, so this path is Linux-only in this tree.
244
+ SETOOLS_BUNDLE = "app-release-exec-linux-SE_FW_x.y.z"
245
+
246
+ #: The J-Link DLL that first shipped Alif's built-in MRAM flash loader. Below
247
+ #: this, Flow D has nothing to program MRAM with.
248
+ JLINK_MIN_DLL = (9, 46)
249
+
250
+ #: The device profile that UNLOCKS that loader. The generic `Cortex-M55` profile
251
+ #: connects fine for read/attach/RAM-run and has no MRAM loader at all, so a
252
+ #: Flow D burn against it silently is not one.
253
+ JLINK_AEN_DEVICE = "AE822FA0E5597LS0_M55_HE"
254
+
255
+ #: The Zephyr SDK release `west sdk install --version` pins. Mirrors
256
+ #: `tan_core::host_env::ZEPHYR_SDK_INSTALL_VERSION` byte-for-byte, so the
257
+ #: `zephyrSdk` check's fix hint below and the Rust oracle's own can never name
258
+ #: two different versions.
259
+ #:
260
+ #: A NEW consumer of the pin `contract/fixtures/toolchains/toolchains.json`
261
+ #: owns -- that fixture's own `_comment` states the rule verbatim: "A NEW
262
+ #: consumer of this pin needs its own parity assertion; widening this scan
263
+ #: will not reach it." `test_zephyr_sdk_install_version_matches_the_real_
264
+ #: toolchain_lock` (test_doctor_command.py) is that assertion, mirroring
265
+ #: `crates/tan-core/src/host_env.rs`'s test of the same name (tan-cli#172) --
266
+ #: without it, an alp-sdk version bump makes Rust fail loudly and this
267
+ #: constant go silently stale.
268
+ ZEPHYR_SDK_INSTALL_VERSION = "1.0.1"
269
+
270
+ #: PATH names west's `.7z` toolchain extraction (via patoolib, which shells
271
+ #: out to an external binary with no pure-Python fallback) will accept --
272
+ #: mirrors `crate::build_readiness::SEVEN_ZIP_PROGRAMS` byte-for-byte. Any ONE
273
+ #: is enough; probing only `7z` would false-negative a host that has `7zz` or
274
+ #: `unar` instead.
275
+ SEVEN_ZIP_PROGRAMS = ("7z", "7za", "7zr", "7zz", "7zzs", "unar")
276
+
277
+ #: Verified resolvable (`winget show 7zip.7zip` -> `Found 7-Zip [7zip.7zip]`,
278
+ #: publisher Igor Pavlov) -- mirrors `crate::build_readiness::
279
+ #: SEVEN_ZIP_INSTALL_COMMAND` byte-for-byte.
280
+ SEVEN_ZIP_INSTALL_COMMAND = "winget install -e --id 7zip.7zip"
281
+
282
+ #: The host platforms the pinned Zephyr SDK (`ZEPHYR_SDK_INSTALL_VERSION`
283
+ #: above) actually publishes a build for -- mirrors
284
+ #: `tan_core::host_env::ZEPHYR_SDK_HOSTS` byte-for-byte (tan-cli#294 finding
285
+ #: 1, reintroducing tan-cli#70). `windows-arm64` was never published at any
286
+ #: release; `macos-x86_64` was dropped in the 1.0.0 line the pinned SDK is
287
+ #: past. Spelled in the SDK's own release-asset tokens (`x86_64`, not `x64`).
288
+ ZEPHYR_SDK_HOSTS = ("linux-aarch64", "linux-x86_64", "macos-aarch64", "windows-x86_64")
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class Check:
293
+ """One verdict. `status` is the Rust `DoctorStatus` vocabulary verbatim:
294
+ `pass` / `warn` / `fail` / `unknown`, where `unknown` means the question was
295
+ not askable on this host -- counted in NO summary bucket and raising no
296
+ issue, so an unverifiable assumption is never rendered as observed fact.
297
+
298
+ `code` overrides the default `doctor.<name>` issue code. It exists for the
299
+ three FROZEN `bootstrap.*` codes (`contract/issue-codes.json`), which
300
+ `alp-sdk-vscode`'s `PREREQ_CODES` matches with `Set.has()` -- an unrecognised
301
+ code there is indistinguishable from "no problem", so the spelling is load-
302
+ bearing and must not be re-derived from the check name.
303
+
304
+ `missing` carries the structured per-tool form of a `hostPrerequisites`
305
+ refusal (tan-cli#294 finding 4: `data.missingPrerequisites`) -- NOT
306
+ serialized by `as_dict()` below, unlike every other field: it does not
307
+ ride on the per-check JSON at all (mirroring Rust's `DoctorCheck`, which
308
+ has no such field either), only on the report-level
309
+ `data.missingPrerequisites` `doctor()` builds from it. Since tan-cli#760
310
+ it is the tan-cli#760-CONFIRMED half: a tool whose manifest command's own
311
+ leading binary (`sudo` included) does not resolve on this host's PATH
312
+ reports `command: null` here, never a string a human or `alp-sdk-
313
+ vscode`'s Fix button could run and fail on.
314
+
315
+ `fix_missing` is `missing`'s RAW twin, tan-cli#760 review MAJOR 1: it
316
+ carries the SAME `{tool, command}` pairs `missing` would carry with no
317
+ confirmation guard applied at all, and is what `doctor()`'s `--fix`
318
+ branch feeds `run_fix` -- never `missing` itself. `run_fix` has done its
319
+ OWN PATH resolution and reported `doctor.fix-installer-not-found` since
320
+ tan-cli#360; feeding it the CONFIRMED (already-nulled) dict instead would
321
+ turn that diagnosis into a silent no-op the moment the installer itself
322
+ is unconfirmed -- exactly the `--fix` silence #360 exists to prevent, on
323
+ the very host (a fresh Mac with no Homebrew, a Windows image with no
324
+ usable winget) #360 was written for. Also NOT serialized, for the same
325
+ reason `missing` is not: it exists purely for `--fix`'s in-process input,
326
+ never the wire.
327
+
328
+ A `hostPrerequisites` Check built with only `missing=` (omitting
329
+ `fix_missing=`) is not left silently `--fix`-disabled: `__post_init__`
330
+ falls back to `missing` itself when `fix_missing` is `None`, matching
331
+ `prerequisites_check`'s own `available=None` behaviour -- the two fields
332
+ equal, pre-#760's shape. A future 9th construction site that forgets the
333
+ pairing therefore fails SAFE (pre-#760 `--fix` behaviour) rather than
334
+ silently.
335
+
336
+ `scope` (tan-cli#549) is `host` or `project` -- see `tan.core.doctor_scope`
337
+ for the two definitions and the judgement calls. REQUIRED and KEYWORD-ONLY,
338
+ which is the enforcement mechanism itself: a check authored without one is
339
+ a `TypeError` at its own construction site, in every branch, on every
340
+ platform, including the ones no test host reaches (`longPaths`, `sevenZip`,
341
+ the six `fix:*` outcomes). A default -- any default -- would put the
342
+ consumer straight back on a hand-list for whatever the default silently
343
+ mislabelled, which is the failure this field exists to end. Keyword-only so
344
+ it can be required without renumbering the positional `fix` argument a
345
+ dozen call sites here already pass positionally.
346
+ """
347
+
348
+ name: str
349
+ status: str
350
+ detail: str
351
+ fix: str | None = None
352
+ code: str | None = None
353
+ #: `str | int | None` values since tan-cli#1066: an entry carries
354
+ #: `sizeBytes` (an int, or `null`) alongside its two string fields.
355
+ missing: list[dict[str, str | int | None]] | None = None
356
+ fix_missing: list[dict[str, str | int | None]] | None = None
357
+ scope: str = field(kw_only=True)
358
+
359
+ def __post_init__(self) -> None:
360
+ # A `str` annotation does not stop `scope=""` or a typo'd `"Host"`, and
361
+ # a consumer filtering on `scope` cannot tell an unrecognised value from
362
+ # a row it is meant to hide -- the same fail-open silence the
363
+ # name-matching seam had. Refuse at construction instead. Every value in
364
+ # this package is a literal that `test_doctor_check_scope.py` already
365
+ # checks statically, so this arm is unreachable from shipping code; it
366
+ # is here for a caller that computes one.
367
+ if self.scope not in CHECK_SCOPES:
368
+ raise ValueError(
369
+ f"doctor check {self.name!r} declares scope {self.scope!r}; "
370
+ f"the wire vocabulary is {CHECK_SCOPES}"
371
+ )
372
+ # tan-cli#760 review round 3, MINOR: a `hostPrerequisites` Check
373
+ # authored with only `missing=` (a 9th call site nobody remembered to
374
+ # pair with `fix_missing=`) must not silently disable `--fix` --
375
+ # `object.__setattr__` because `Check` is frozen, and this is the one
376
+ # place a divergent-by-omission construction can still be caught and
377
+ # repaired rather than reddening `run_fix` with a `None` input. Falls
378
+ # back to `missing` itself, matching `prerequisites_check`'s own
379
+ # `available=None` behaviour (pre-#760: the two fields identical) --
380
+ # fails SAFE at pre-#760 `--fix` behaviour, never silent. A no-op
381
+ # when `missing` is also `None` (every non-`hostPrerequisites` Check).
382
+ if self.fix_missing is None:
383
+ object.__setattr__(self, "fix_missing", self.missing)
384
+
385
+ def as_dict(self) -> dict:
386
+ # `scope` between `status` and `detail`: the three machine-read fields
387
+ # first, the prose last. Key ORDER is not a contract on this seam
388
+ # (`contract/README.md`, "What a golden does NOT cover: key ORDER") --
389
+ # both conformance harnesses compare parsed maps -- so this is
390
+ # readability, not compatibility.
391
+ out = {
392
+ "name": self.name,
393
+ "status": self.status,
394
+ "scope": self.scope,
395
+ "detail": self.detail,
396
+ }
397
+ # Omitted when absent, not null -- Rust's `skip_serializing_if`.
398
+ if self.fix is not None:
399
+ out["fix"] = self.fix
400
+ return out
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Probing. `probe_status`/`probe` (tan.core.probe) are every subprocess this
405
+ # module's checks read a tool's own answer through; `on_path` below is the
406
+ # PATH-only presence walk. Every filesystem read goes through one of these
407
+ # three, and none of them can raise. `probe_status`/`probe`/`PROBE_TIMEOUT_S`
408
+ # moved to `tan.core.probe` (tan-cli#797 module-size budget) so
409
+ # `tan.core.doctor_git` could depend on them without an import cycle back
410
+ # into this file; re-imported here so every existing `probe(...)` call site
411
+ # below, and every test referencing `doctor_cmd.probe`/`doctor_cmd.
412
+ # PROBE_TIMEOUT_S`, keeps resolving unchanged.
413
+ # ---------------------------------------------------------------------------
414
+
415
+
416
+ def on_path(command: str) -> str | None:
417
+ """Resolve `command` against `$PATH`, returning its full path.
418
+
419
+ NOT `shutil.which`: on Windows that inserts `os.curdir` ahead of PATH
420
+ (documented Windows search order), so a project checked out with its own
421
+ `west.exe`/`openocd.exe` at its root would be reported as this host's
422
+ tooling -- and a later flow would spawn exactly that project-controlled
423
+ binary.
424
+
425
+ ONE LOOKUP, NOT A SIXTH COPY (tan-cli#532). This used to hand-roll the
426
+ walk; it now delegates to `tan.core.tool_lookup.resolve_tool`, which
427
+ `build/execute.py`, `flash_cmd.py` and `size_cmd.py` already call.
428
+ `faultdecode_cmd` reaches the lookup THROUGH here, so this retires the
429
+ last two of the five copies. Only the FINDING is shared -- the signature
430
+ stays `str | None` so each caller keeps its own policy about a miss; ask
431
+ `resolve_tool` directly for its `searched` record.
432
+
433
+ WINDOWS BEHAVIOUR CHANGED, deliberately: the old copy's `[""] + %PATHEXT%`
434
+ accepted a bare extension-less `%PATH%` file ahead of every suffixed
435
+ sibling, and `resolve_tool` never tries the bare name. Reasoning and the
436
+ oracle comparison are in `tool_lookup`'s docstring; pinned by
437
+ `test_bare_argv0_spawn.py::
438
+ test_the_windows_walk_never_considers_the_bare_extensionless_name`, and
439
+ the delegation itself by `test_doctor_on_path_consolidation.py`. POSIX is
440
+ unaffected.
441
+
442
+ `os.environ`, not a threaded env: this is the doctor's HOST probe. Paths
443
+ resolving against a constructed environment (a workspace venv prepended)
444
+ call `resolve_tool` with that env and never come through here.
445
+ """
446
+ return resolve_tool(command, os.environ).resolved
447
+
448
+
449
+ def _read_text(path: Path) -> str | None:
450
+ try:
451
+ return path.read_text(encoding="utf-8", errors="replace")
452
+ except (OSError, ValueError):
453
+ return None
454
+
455
+
456
+ # ---------------------------------------------------------------------------
457
+ # Version floors
458
+ # ---------------------------------------------------------------------------
459
+
460
+
461
+ def _parse_two(raw: str) -> tuple[int, int] | None:
462
+ """`"3.12"`, `"v1.2.0"`, `"West version: v1.2.0"` -> `(major, minor)`."""
463
+ match = re.search(r"(\d+)\.(\d+)", raw)
464
+ if match is None:
465
+ return None
466
+ return (int(match.group(1)), int(match.group(2)))
467
+
468
+
469
+ def jlink_flash_device(sdk_root: str | None) -> tuple[str, str]:
470
+ """The Flow-D part-number J-Link device profile, and where it came from.
471
+
472
+ Read from `<sdk>/metadata/socs/alif/ensemble/e8.json`
473
+ `variants[].debug.jlink_flash_device` -- the ONE variant carrying that key
474
+ is the one with an MRAM loader profile at all; the other AE822 package
475
+ variant's `debug` has a `jlink_device` (attach) entry but no
476
+ `jlink_flash_device`, because it has no Flow D loader to unlock.
477
+
478
+ `JLINK_AEN_DEVICE` is the fallback for THREE distinct causes, and the
479
+ returned source string names WHICH one fired (tan-cli#310) -- they used
480
+ to collapse into one sentence that only ever matched the first, so a host
481
+ with a perfectly good SDK checkout got told "no alp-sdk checkout
482
+ resolved" in the same envelope that reported resolving one:
483
+
484
+ 1. no `sdk_root` at all -- the honest "nothing to read from" case;
485
+ 2. `sdk_root` resolved but `e8.json` is missing, unreadable, or does not
486
+ parse as a JSON object -- named with the exact path that was tried;
487
+ 3. `sdk_root` resolved and `e8.json` parsed fine, but no variant carries
488
+ `debug.jlink_flash_device` -- the real state of a checkout predating
489
+ alp-sdk#1057, which publishes this fact into a per-board `flash_args`
490
+ value instead; doctor has no board selected to read one from, so the
491
+ built-in constant is the honest answer, not a resolution failure.
492
+
493
+ Every variant is checked, not just the first hit: if a future package
494
+ variant declares a DIFFERENT `jlink_flash_device`, picking whichever
495
+ serialises first would silently advise the wrong part with nothing to
496
+ catch it. More than one DISTINCT value is ambiguous, not resolved -- it
497
+ falls back to `JLINK_AEN_DEVICE` with a source that says so, rather than
498
+ guessing.
499
+
500
+ Never raises: a missing SDK, an unreadable or malformed `e8.json`, or no
501
+ variant carrying the key all fall back the same way -- doctor's whole job
502
+ is to run on a host where things are wrong.
503
+ """
504
+ if not sdk_root:
505
+ return JLINK_AEN_DEVICE, (
506
+ f"tan's built-in fallback {JLINK_AEN_DEVICE} -- no alp-sdk checkout "
507
+ "resolved to read metadata/socs/alif/ensemble/e8.json "
508
+ "variants[].debug.jlink_flash_device from"
509
+ )
510
+
511
+ path = Path(sdk_root) / "metadata" / "socs" / "alif" / "ensemble" / "e8.json"
512
+ text = _read_text(path)
513
+ doc = None
514
+ if text is not None:
515
+ try:
516
+ doc = json.loads(text)
517
+ except ValueError:
518
+ doc = None
519
+ if not isinstance(doc, dict):
520
+ return JLINK_AEN_DEVICE, (
521
+ f"tan's built-in fallback {JLINK_AEN_DEVICE} -- {path} is missing, "
522
+ "unreadable, or did not parse as a JSON object, so its "
523
+ "variants[].debug.jlink_flash_device could not be read"
524
+ )
525
+
526
+ found: set[str] = set()
527
+ for variant in doc.get("variants") or []:
528
+ if not isinstance(variant, dict):
529
+ continue
530
+ debug = variant.get("debug")
531
+ device = debug.get("jlink_flash_device") if isinstance(debug, dict) else None
532
+ if isinstance(device, str) and device:
533
+ found.add(device)
534
+ if len(found) == 1:
535
+ return next(iter(found)), str(path)
536
+ if len(found) > 1:
537
+ return JLINK_AEN_DEVICE, (
538
+ f"tan's built-in fallback {JLINK_AEN_DEVICE} -- {path} "
539
+ f"variants[].debug.jlink_flash_device carries {len(found)} "
540
+ "DIFFERENT values across variants (ambiguous), refusing to "
541
+ "pick one arbitrarily"
542
+ )
543
+ return JLINK_AEN_DEVICE, (
544
+ f"tan's built-in fallback {JLINK_AEN_DEVICE} -- {path} parsed but no "
545
+ "variant carries debug.jlink_flash_device; alp-sdk#1057 publishes this "
546
+ "profile into a per-board flash_args value instead, and doctor has no "
547
+ "board selected to read one from"
548
+ )
549
+
550
+
551
+ def _fmt(version: tuple[int, int]) -> str:
552
+ return f"{version[0]}.{version[1]}"
553
+
554
+
555
+ # ---------------------------------------------------------------------------
556
+ # The checks. Pure: probed facts in, a verdict out.
557
+ # ---------------------------------------------------------------------------
558
+
559
+
560
+ def python_check(
561
+ found: tuple[str, tuple[int, int]] | None, floor: tuple[int, int], floor_source: str
562
+ ) -> Check:
563
+ """`hostPython` -- is there an interpreter, and does it clear the EFFECTIVE
564
+ floor?
565
+
566
+ `found` is `(how it is spelled, (major, minor))` for the best candidate that
567
+ actually RAN. `None` is not "too old", it is "nothing runs": the Microsoft
568
+ Store `python.exe` alias satisfies any presence check and prints nothing,
569
+ which is why the probe insists on parseable output rather than existence.
570
+ """
571
+ if found is None:
572
+ return Check(
573
+ "hostPython",
574
+ "fail",
575
+ "no runnable Python interpreter found -- none of `python3`/`python`"
576
+ + (" / `py -3`" if os.name == "nt" else "")
577
+ + " ran and reported a version.",
578
+ "Install Python "
579
+ + _fmt(floor)
580
+ + "+ and put it on PATH."
581
+ + (
582
+ " On Windows, a `python.exe` that opens the Microsoft Store is the"
583
+ " Store ALIAS, not an interpreter: disable it under Settings > Apps >"
584
+ " App execution aliases, or install from python.org."
585
+ if os.name == "nt"
586
+ else ""
587
+ ),
588
+ # FROZEN (contract/issue-codes.json). Spelled, never derived.
589
+ code="bootstrap.python-not-runnable",
590
+ scope="host",
591
+ )
592
+ binary, version = found
593
+ if version < floor:
594
+ return Check(
595
+ "hostPython",
596
+ "fail",
597
+ f"Python {_fmt(version)} (`{binary}`) is below the effective floor "
598
+ f"{_fmt(floor)}, which comes from {floor_source}. The build does not "
599
+ f"fail here -- it fails later, inside Zephyr's own CMake configure, "
600
+ f"with an error that names Zephyr rather than your Python.",
601
+ f"Install Python {_fmt(floor)}+ and put it ahead of {_fmt(version)} on PATH, "
602
+ f"then re-run `tan bootstrap` so the workspace venv is built with it."
603
+ + (
604
+ # Named because it is THE case: the distro `python3` on 22.04 is
605
+ # 3.10, which clears the manifest floor and dies at Zephyr's
606
+ # configure -- the exact host this check exists for.
607
+ f" Ubuntu 22.04's distro `python3` is 3.10, so this needs a newer one: "
608
+ f"`sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt-get install -y "
609
+ f"python{_fmt(floor)} python{_fmt(floor)}-venv`."
610
+ if sys.platform.startswith("linux")
611
+ else ""
612
+ ),
613
+ # FROZEN (contract/issue-codes.json).
614
+ code="bootstrap.python-too-old",
615
+ scope="host",
616
+ )
617
+ return Check(
618
+ "hostPython",
619
+ "pass",
620
+ f"Python {_fmt(version)} (`{binary}`) meets the effective floor "
621
+ f"{_fmt(floor)} ({floor_source}).",
622
+ scope="host",
623
+ )
624
+
625
+
626
+ def host_python_check(
627
+ found: tuple[str, tuple[int, int]] | None,
628
+ floor: tuple[int, int],
629
+ floor_source: str,
630
+ has_workspace_venv: bool,
631
+ candidates: list | None = None,
632
+ ) -> Check:
633
+ """`hostPython` as `python_check` judges it, downgraded to `warn` (same id,
634
+ no code) when it would pass but `tan build` would have no usable
635
+ interpreter: no workspace venv, and no PATH Python that is >= `floor` AND
636
+ imports `west` (tan-cli#1317, same resolver as the build)."""
637
+ check = python_check(found, floor, floor_source)
638
+ if check.status != "pass" or has_workspace_venv:
639
+ return check
640
+ if candidates is None:
641
+ candidates = probe_all_host_pythons()
642
+ candidates = list(candidates)
643
+ best = select_host_python(candidates, floor, need_west=True)
644
+ if best is not None and best.version >= floor and best.has_west:
645
+ return check
646
+ return Check(
647
+ "hostPython",
648
+ "warn",
649
+ f"{check.detail} But {describe_unsuitable(candidates, floor)}",
650
+ "Run `tan bootstrap` (or activate the Zephyr workspace venv).",
651
+ scope="host",
652
+ )
653
+
654
+
655
+ def python_floor_skew_check(
656
+ manifest_floor: tuple[int, int],
657
+ effective_floor: tuple[int, int],
658
+ effective_source: str,
659
+ manifest_is_real: bool = True,
660
+ ) -> Check | None:
661
+ """`pythonFloor` -- the two declared floors disagree.
662
+
663
+ Reported rather than silently reconciled. A host that satisfies the higher
664
+ floor is fine TODAY, but the manifest is the number a customer will read and
665
+ trust, so while the skew stands the two sources disagree about which hosts
666
+ are supported. Saying which number came from which file is the whole value.
667
+
668
+ **Not fixed by raising the manifest (tan-cli#300).** That was tried and
669
+ reverted -- alp-sdk#1078: `crates/tan-core/src/build_readiness.rs:401`
670
+ pushes the Python check BEFORE any `os_set` branch ("EVERY backend's
671
+ build-plan emission runs `alp_project.py` ... not just Zephyr's"), so
672
+ raising the shared `pythonMinVersion` key would refuse a Yocto-only or
673
+ metadata-only project, on a host that builds it fine today, over a floor
674
+ that project never needs -- and the raised floor is unreachable via the
675
+ manifest's own remedy (`sudo apt-get install -y python3`) on the Ubuntu
676
+ 22.04 hosts the docs recommend. The skew is real and known, and scoped to
677
+ Zephyr; the fix for a Zephyr build on a below-floor host is a newer
678
+ interpreter on THAT host (see `hostPython` above), not a manifest edit.
679
+
680
+ `manifest_is_real` is `False` when `manifest_floor` never actually came from
681
+ a read `metadata/bootstrap.json` -- no SDK resolved, or this SDK predates
682
+ the manifest -- and is instead tan's own `FALLBACK_PYTHON_FLOOR` standing
683
+ in. Callers pass `_load_manifest`'s own `ManifestLoad.is_real` verdict
684
+ straight through -- never re-derived from `ManifestLoad.source`'s prose, so
685
+ a future rewording of that message cannot silently flip which branch below
686
+ fires. Misreporting that number as "alp-sdk's metadata/bootstrap.json
687
+ declares" sends the customer to edit a file that was never consulted, so
688
+ the wording and the fix both change for this case.
689
+
690
+ `tan bootstrap` enforces the SAME effective floor this reports -- it calls
691
+ `zephyr_python_floor` below with the same argument (see
692
+ `tan.commands.bootstrap_cmd.resolve_python_floor`) and raises
693
+ `bootstrap.python-floor-skew` with the same two numbers. Before that, the
694
+ Rust oracle's POSIX branch enforced only the manifest's, which is how a
695
+ 3.10 host passed both commands and then died inside Zephyr's CMake
696
+ configure.
697
+ """
698
+ if manifest_floor >= effective_floor:
699
+ return None
700
+ if manifest_is_real:
701
+ claim = f"alp-sdk's metadata/bootstrap.json declares pythonMinVersion {_fmt(manifest_floor)}"
702
+ fix = (
703
+ f"Known, Zephyr-scoped skew (alp-sdk#1078) -- raising "
704
+ f"`prerequisites.pythonMinVersion` to {_fmt(effective_floor)} was tried "
705
+ f"and reverted, because that key also gates Yocto-only and "
706
+ f"metadata-only projects, which do not need it. Building for Zephyr on "
707
+ f"a host below {_fmt(effective_floor)} needs a newer interpreter -- see "
708
+ f"the `hostPython` check above."
709
+ )
710
+ else:
711
+ claim = (
712
+ f"no alp-sdk metadata/bootstrap.json was read (no SDK checkout resolved, "
713
+ f"or this SDK predates it), so tan's own built-in floor {_fmt(manifest_floor)} "
714
+ f"is standing in"
715
+ )
716
+ fix = (
717
+ # `tan sdk switch` refuses in this build (tan-cli#305) -- point at
718
+ # the mechanism that actually resolves one instead.
719
+ f"Resolve an alp-sdk checkout: {NO_SDK_NEXT_STEPS}. That checkout's "
720
+ "own metadata/bootstrap.json pythonMinVersion is then read instead "
721
+ "of tan's built-in floor."
722
+ )
723
+ return Check(
724
+ "pythonFloor",
725
+ "warn",
726
+ f"{claim}, but the build's effective floor is "
727
+ f"{_fmt(effective_floor)} (from {effective_source}). Both `tan doctor` and "
728
+ f"`tan bootstrap` enforce the higher, effective floor, so a host this "
729
+ f"manifest would have accepted is refused up front rather than failing "
730
+ f"later at Zephyr's CMake configure.",
731
+ fix,
732
+ scope="project",
733
+ )
734
+
735
+
736
+ def prerequisites_check(
737
+ checked: list[str],
738
+ missing: list[str],
739
+ install: dict[str, str],
740
+ source: str,
741
+ venv_refusal: PrereqFailure | None = None,
742
+ *,
743
+ available: Callable[[str], bool] | None = None,
744
+ provenance: dict[str, ArtifactProvenance] | None = None,
745
+ ) -> Check:
746
+ """`hostPrerequisites` -- the manifest's own tool list, on PATH, PLUS
747
+ (Linux only) whether the interpreter's `venv` module can actually create
748
+ a usable environment (tan-cli#294 finding 3, reintroducing tan-cli#161).
749
+
750
+ Mirrors `tan_core::bootstrap::doctor_prerequisite_check`, including that
751
+ the per-tool install commands come from the manifest rather than being
752
+ spelled here: they are per-platform facts alp-sdk owns.
753
+
754
+ `venv_refusal` is `posix_venv_unusable()` when `python3` is on PATH and
755
+ ran, but its `venv` module cannot create a usable environment because
756
+ `ensurepip` is missing -- the Debian/Ubuntu `python3-venv` package split.
757
+ Before this, `tan doctor` probed bare PATH presence and never
758
+ `ensurepip`, so it passed on a host that then died at `tan bootstrap`
759
+ time. `venv_refusal.missing` (`{tool: "python3-venv", command: ...}`)
760
+ folds into this check's own `missing`/`fix_missing` fields alongside any
761
+ tool-presence entries, so one `data.missingPrerequisites` list
762
+ (finding 4) carries both failure shapes -- never two.
763
+
764
+ `available` (tan-cli#760 + review #765) is the PATH-confirmation guard --
765
+ `install` here is RAW, exactly as the manifest declares it, and `install`
766
+ itself is used UNGUARDED to build `fix_missing`, the field `--fix`
767
+ (`doctor_cmd.run_fix`) actually consumes. `missing` -- `data.
768
+ missingPrerequisites`, the customer-facing field -- is the CONFIRMED
769
+ projection of the same entries: `available(binary)` decides whether a
770
+ command's own leading binary (`sudo` included) resolves on this host
771
+ before it is ever handed out as `command`, never `None`. `available=None`
772
+ (every direct caller before tan-cli#760, and every existing unit test of
773
+ this function) means `missing` and `fix_missing` are identical -- no
774
+ guard at all, matching this function's pre-#760 behaviour exactly.
775
+
776
+ `provenance` (tan-cli#1066) is the manifest's own `artifactProvenance`
777
+ table, `tool -> {tier, licence, sourceUrl, sizeBytes}`, joined onto each
778
+ entry by `tool` -- the identity the entry already carries. It reaches BOTH
779
+ `missing` and `fix_missing`, unlike `available` above: the two differ over
780
+ what tan may promise to RUN, and a licence is not a promise. `None` (every
781
+ caller before #1066, and the fallback manifest, which publishes no
782
+ provenance at all) reports the same `null`s a tool with no entry does --
783
+ `west`, `zephyrSdk`, `setools`, `jlink` and `python3-venv` have none in
784
+ alp-sdk v0.16.0, and that gap is alp-sdk#1574's, not tan's to fill in.
785
+
786
+ This split exists because `run_fix` has done its OWN on_path resolution
787
+ and reported `doctor.fix-installer-not-found` since tan-cli#360: handing
788
+ it the CONFIRMED (already-nulled) dict instead of the raw one would make
789
+ every unconfirmed installer a silent `--fix` no-op -- trading away the
790
+ #360 diagnostic for the #760 fix, on the exact hosts (a fresh Mac with no
791
+ Homebrew, a Windows image with no usable winget) #360 exists for
792
+ (tan-cli#760 review, MAJOR 1).
793
+ """
794
+ raw_entries = tuple(
795
+ MissingPrerequisite(
796
+ tool, install.get(tool), artifact_provenance.for_tool(provenance, tool)
797
+ )
798
+ for tool in missing
799
+ )
800
+ if venv_refusal is not None:
801
+ # `posix_venv_unusable()`'s own entry keeps its `UNKNOWN` provenance:
802
+ # `python3-venv` is a Debian PACKAGE name, not one of alp-sdk's
803
+ # `artifactProvenance` keys, so there is nothing to join and a
804
+ # near-miss join (`python3`'s row) would attribute one artefact's
805
+ # licence to another (tan-cli#1066).
806
+ raw_entries = raw_entries + venv_refusal.missing
807
+ fix_missing = reported_missing(raw_entries)
808
+
809
+ entries = raw_entries if available is None else confirm_missing(raw_entries, available)
810
+ missing_data = reported_missing(entries)
811
+ confirmed_commands = [m.command for m in entries if m.tool in missing and m.command]
812
+
813
+ if missing:
814
+ # tan-cli#760 review MINOR 4: `tan bootstrap`'s sibling refusal
815
+ # (`bootstrap.posix_refusal`/`_doctor_fix_hint`) already stops
816
+ # promising a remedy it cannot perform when NO missing tool has a
817
+ # confirmed command; this Check's own `fix` prose gets the same
818
+ # treatment, naming the tools rather than falling silent.
819
+ if confirmed_commands:
820
+ fix_text = (
821
+ "Install the missing prerequisites, then run `tan bootstrap`."
822
+ f" {'; '.join(confirmed_commands)}"
823
+ )
824
+ elif available is not None:
825
+ fix_text = (
826
+ f"tan has no confirmed install command for this host: "
827
+ f"{', '.join(missing)}. Install them with your OS's package "
828
+ f"manager and put them on PATH, then run `tan bootstrap`."
829
+ )
830
+ else:
831
+ fix_text = "Install the missing prerequisites, then run `tan bootstrap`."
832
+ return Check(
833
+ "hostPrerequisites",
834
+ "fail",
835
+ f"missing from PATH: {', '.join(missing)} ({source}).",
836
+ fix_text,
837
+ # FROZEN (contract/issue-codes.json).
838
+ code="bootstrap.prerequisites-missing",
839
+ missing=missing_data,
840
+ fix_missing=fix_missing,
841
+ scope="host",
842
+ )
843
+ if venv_refusal is not None:
844
+ return Check(
845
+ "hostPrerequisites",
846
+ "fail",
847
+ f"{' '.join(venv_refusal.lines)} ({source}).",
848
+ "Install the missing prerequisites, then run `tan bootstrap`.",
849
+ code=f"bootstrap.{venv_refusal.code}",
850
+ missing=missing_data,
851
+ fix_missing=fix_missing,
852
+ scope="host",
853
+ )
854
+ return Check(
855
+ "hostPrerequisites", "pass", f"{', '.join(checked)} present ({source}).",
856
+ scope="host",
857
+ )
858
+
859
+
860
+ def _posix_venv_capable(argv: list[str], executable: str | None = None) -> bool:
861
+ """Whether `argv`'s Python can create a USABLE virtual environment
862
+ (tan-cli#161). `python -m venv --help` cannot tell -- argparse answers
863
+ before `ensurepip` is ever touched -- so this probes the real
864
+ dependency: `import ensurepip`, which fails fast on the Debian/Ubuntu
865
+ split where `python3-venv` is a separate, unmet package.
866
+
867
+ Fails OPEN, not closed (tan-cli#294 review): `True` both when the probe
868
+ ran and exited 0, AND when it could not be launched at all (bogus argv,
869
+ spawn failure, signal death) -- mirrors `crate::util::
870
+ python_venv_capable`'s `.output().map(|out| out.status.success())
871
+ .unwrap_or(true)` verdict, not only its probed command; the real `python
872
+ -m venv` a moment later surfaces its own error if something is genuinely
873
+ wrong. Only a probe that actually RAN and exited non-zero refuses the
874
+ host.
875
+
876
+ NOT built on this file's own `probe()`: `probe()` collapses "ran and
877
+ exited non-zero" and "could not run at all" to the same `None`, and
878
+ those two outcomes need OPPOSITE verdicts here.
879
+
880
+ `executable` -- the PATH-RESOLVED interpreter path (tan-cli#797), e.g.
881
+ `_probe_host_python`'s own third return element. `argv` stays the bare
882
+ display spelling for the child's own `argv[0]`; only the OS-level lookup
883
+ is pinned, the same split every other resolved spawn in this module uses.
884
+ `None` is accepted (rather than required) so a caller that has not
885
+ resolved the interpreter still gets the pre-existing bare-spawn behaviour
886
+ -- there is no bare-git-style "refuse outright" case here, since `argv`
887
+ always comes from a candidate `_probe_host_python` already ran once.
888
+ """
889
+ try:
890
+ # Empty cwd: `-c` puts the cwd on sys.path (module-hijack, tan-cli#1317).
891
+ with tempfile.TemporaryDirectory(prefix="tan-probe-") as empty:
892
+ result = subprocess.run(
893
+ [*argv, "-c", "import ensurepip"],
894
+ cwd=empty,
895
+ executable=executable,
896
+ capture_output=True,
897
+ text=True,
898
+ encoding="utf-8",
899
+ errors="replace",
900
+ stdin=subprocess.DEVNULL,
901
+ timeout=PROBE_TIMEOUT_S,
902
+ env=spawn_env(),
903
+ check=False,
904
+ )
905
+ except (OSError, ValueError, subprocess.SubprocessError):
906
+ return True
907
+ return result.returncode == 0
908
+
909
+
910
+ def west_check(
911
+ found: str | None,
912
+ version: tuple[int, int] | None,
913
+ floor: tuple[int, int] | None,
914
+ resolved: str | None = None,
915
+ resolved_ran: bool = True,
916
+ ) -> Check:
917
+ """`west` -- present on BARE PATH, or resolvable through the SAME
918
+ resolver `westResolved` uses (`tan.core.venv.west_program`).
919
+
920
+ **Now consults the resolver (tan-cli#299 second half).** This docstring
921
+ used to argue the opposite:
922
+
923
+ Does NOT assert that the venv resolved one: this check cannot see
924
+ what `westResolved` found... Name the authority instead of
925
+ predicting its answer.
926
+
927
+ That was deliberate at the time: `found` (bare PATH) was this check's
928
+ ONLY signal, so a hard `fail` here on the default post-bootstrap state --
929
+ `tan bootstrap` deliberately does NOT put `west` on PATH; its own
930
+ next-steps text tells the user to activate the venv afterwards -- was a
931
+ false, exit-4 refusal of a host that provably builds. Measured on the
932
+ published v0.5.0-rc2 binary: `tan build` produced real ELFs through the
933
+ resolved venv `west` while this check alone reported the host broken.
934
+ Downgrading that `fail` to `warn` (this file's other, earlier change on
935
+ this branch) fixed the exit code, but the warning still fires on every
936
+ correctly-bootstrapped host's very first `tan doctor` -- and a warning
937
+ that fires on every correct install trains users to ignore warnings,
938
+ which is the same defect as the false `fail`, one severity down
939
+ (hkngln, tan-cli#299).
940
+
941
+ So this now takes `resolved`: the SAME absolute venv path `westResolved`
942
+ already computed via `tan.core.venv.west_program` -- never a second,
943
+ independent probe of its own (`tool_in_venv` already confirmed that file
944
+ exists before `westResolved` ever saw it) -- and reports `pass`, naming
945
+ it, when bare PATH lacks `west` but the resolver found one. A bare-PATH
946
+ probe that cannot see the venv was never a second opinion; it was a
947
+ worse one. It now defers to the real one instead of contradicting it.
948
+
949
+ **Still never the FAIL owner.** When `resolved` is ALSO `None` -- west
950
+ absent from PATH and unresolvable anywhere -- this stays `warn`, not
951
+ `fail`. That severity belongs to `westResolved` alone (below), by
952
+ tan-cli#123's one-version-per-check contract applied to severity: making
953
+ both checks fatal on the same absent-everywhere fact is the two-owners
954
+ bug tan-cli#123 closed, and reintroducing it is exactly what let west
955
+ absent everywhere exit 0 the one time this branch made BOTH checks
956
+ non-fatal at once, before `west_resolved_check` was raised back to
957
+ `fail`. Keeping `west` a `warn` even in that state is what lets
958
+ `westResolved` be the sole, unambiguous reason `tan doctor` exits 4 on a
959
+ genuinely unbuildable host.
960
+
961
+ Only WARN on an old or unreadable version too -- west is forward-
962
+ compatible in practice and refusing a host on a version string we could
963
+ not parse is a worse failure than letting the real invocation report its
964
+ own.
965
+
966
+ **`resolved_ran` -- a resolved-but-unspawnable west is not a `pass`
967
+ (tan-cli#488 defect 3).** `resolved` only proves `westResolved` found a
968
+ file at that path; it says nothing about whether that file can actually
969
+ run. Before this parameter existed, `found is None and resolved is not
970
+ None` returned `pass` unconditionally -- including for a `west` whose
971
+ launcher survives a relocated workspace but whose interpreter does not,
972
+ the exact host `westResolved` (tan-cli#488 defect 1) now reports `fail`
973
+ for. That paired one check correctly failing an unbuildable host with its
974
+ sibling still calling it a `pass`, the headline "pass on an unrunnable
975
+ west" symptom surviving in this one branch. Defaults to `True` so every
976
+ existing 4-argument call site (this file's own unit tests, modelling the
977
+ ordinary "ran fine" case) keeps its prior meaning unchanged.
978
+ """
979
+ if found is None:
980
+ if resolved is not None and resolved_ran:
981
+ return Check(
982
+ "west",
983
+ "pass",
984
+ f"`west` is not on bare PATH, but resolves through the workspace "
985
+ f"venv: {resolved} -- the same binary `westResolved` above "
986
+ f"reports, and the one a real build actually spawns. This is the "
987
+ f"default state right after `tan bootstrap`, which deliberately "
988
+ f"does not put `west` on PATH; activating the venv (its "
989
+ f"`bin`/`Scripts` directory holds the `west` launcher) would "
990
+ f"additionally put it on bare PATH, for tools that spawn it "
991
+ f"directly rather than through tan.",
992
+ scope="host",
993
+ )
994
+ if resolved is not None:
995
+ return Check(
996
+ "west",
997
+ "warn",
998
+ f"`west` is not on bare PATH. It resolves to {resolved} through the "
999
+ f"workspace venv, but `westResolved` above found that binary could "
1000
+ f"not be executed there -- no build slice can run it. `westResolved` "
1001
+ f"is the check that owns this failure; see it for the remedy.",
1002
+ "Run `tan bootstrap` to recreate the workspace venv.",
1003
+ scope="host",
1004
+ )
1005
+ return Check(
1006
+ "west",
1007
+ "warn",
1008
+ # Does NOT assert that the venv resolved one: `resolved` above
1009
+ # already covers that case with a `pass`, so reaching here means
1010
+ # it is genuinely `None` too -- PATH absence on its own, with
1011
+ # nothing for the resolver to find either. Name the authority
1012
+ # instead of predicting its answer.
1013
+ "`west` is not on bare PATH. `westResolved` above is the check that "
1014
+ "answers whether a build slice can run -- it reports the binary one "
1015
+ "would actually execute. PATH absence on its own is the normal state "
1016
+ "before the workspace venv is activated in this shell.",
1017
+ "If `westResolved` above also could not resolve one, run `tan "
1018
+ "bootstrap`; otherwise activate the workspace venv (its `bin`/`Scripts` "
1019
+ "directory holds the `west` launcher) so tools invoked directly find it "
1020
+ "too.",
1021
+ scope="host",
1022
+ )
1023
+ if version is None:
1024
+ return Check(
1025
+ "west",
1026
+ "warn",
1027
+ f"`west` found at {found} but `west --version` produced nothing this "
1028
+ f"command could parse.",
1029
+ "Run `west --version` by hand; a west that cannot report its version "
1030
+ "usually cannot run either.",
1031
+ scope="host",
1032
+ )
1033
+ if floor is not None and version < floor:
1034
+ return Check(
1035
+ "west",
1036
+ "warn",
1037
+ f"west {_fmt(version)} ({found}) is older than the {_fmt(floor)} floor "
1038
+ f"alp-sdk's metadata/bootstrap.json pins.",
1039
+ "Upgrade inside the workspace venv: `pip install --upgrade west`.",
1040
+ scope="host",
1041
+ )
1042
+ return Check("west", "pass", f"west {_fmt(version)} ({found}).", scope="host")
1043
+
1044
+
1045
+ def west_resolved_check(
1046
+ found: str | None, version: tuple[int, int] | None, ran: bool = True
1047
+ ) -> Check:
1048
+ """`westResolved` -- is `west` resolved through the WORKSPACE VENV
1049
+ (`tan.core.venv.west_program`), not bare PATH (tan-cli#123/#290)?
1050
+
1051
+ Distinct from `west` above, which probes `on_path("west")` ONLY: on a
1052
+ host where the workspace venv holds `west` but PATH does not -- the
1053
+ normal GUI-launched-editor state, `tan.core.venv`'s own module
1054
+ docstring -- `west` reports failing while a real build succeeds through
1055
+ the venv binary. `westResolved` verifies the SAME binary a build would
1056
+ actually run, and `version` (when probed) MUST come from that identical
1057
+ resolution -- never a second, bare-PATH re-probe. Mirrors
1058
+ `tan_core::preflight::build_preflight_checks`'s `westResolved`
1059
+ (`west_available`) check, unconditional in BOTH doctor modes exactly like
1060
+ `sdk`/`workspace` beside it (`crates/tan-cli/src/commands/doctor.rs:1828`
1061
+ asserts all three together in the plain fold).
1062
+
1063
+ **FAIL when west resolves nowhere.** This used to be a Warn, justified by
1064
+ "`west` above already fails outright on a totally-absent west, so this is
1065
+ the narrower, additive fact". tan-cli#299 removed that Fail -- correctly,
1066
+ because bare PATH is the wrong question -- and thereby falsified the
1067
+ premise this severity rested on. Measured on a real host with `west.exe`
1068
+ renamed out of the venv and absent from PATH:
1069
+
1070
+ westResolved warn west not resolved through the workspace venv or PATH
1071
+ west warn ... every build slice actually resolves it through the venv
1072
+ 12 passed, 4 warning(s), 0 failed. EXIT=0
1073
+
1074
+ Exit 0 on a host where nothing can execute a single slice, and `west`'s
1075
+ text asserting the venv resolves it while THIS check says it does not. A
1076
+ false refusal was traded for a false pass, which is the worse of the two.
1077
+
1078
+ So the pair now splits cleanly: `west` answers "is it on bare PATH" and is
1079
+ never fatal (an unactivated venv is the normal post-bootstrap state);
1080
+ `westResolved` answers "can a build slice run at all" and is fatal when the
1081
+ answer is no. Exactly one of them owns the exit code, which is tan-cli#123's
1082
+ one-version-per-check contract applied to severity.
1083
+
1084
+ **`ran` -- spawn failure is ALSO fatal, not a `pass` (tan-cli#488 defect
1085
+ 1).** `found` only proves `tool_in_venv`'s `is_file()` check passed; it
1086
+ says nothing about whether the file can actually be EXECUTED. Before
1087
+ `ran` existed, this function took `version` alone and treated
1088
+ `version is None` as `pass` unconditionally -- but `probe()` collapses
1089
+ THREE outcomes into that same `None`: could not be spawned at all
1090
+ (`OSError` -- a `west` launcher whose shebang names a now-absent
1091
+ interpreter, e.g. a relocated/renamed workspace, or a deleted
1092
+ `.venv/bin/python`), spawned and exited non-zero, and spawned, exited
1093
+ zero, and printed something this file's regex could not parse. `pass` is
1094
+ only right for the third. `ran` (from `probe_status`, passed by
1095
+ `_collect`) is `True` for exactly that third case and `False` for the
1096
+ first two, so a resolved-but-unrunnable `west` now reports the SAME
1097
+ `fail` as a `west` that resolved nowhere at all -- both mean "no build
1098
+ slice can be executed" and both send the customer to the same remedy,
1099
+ `tan bootstrap`. Defaults to `True` so every existing 2-argument call
1100
+ site (this file's own unit tests, modelling the ordinary "ran fine"
1101
+ cases) keeps its prior meaning unchanged.
1102
+ """
1103
+ if found is None:
1104
+ return Check(
1105
+ "westResolved",
1106
+ "fail",
1107
+ "west resolved neither through the workspace venv nor PATH -- no build "
1108
+ "slice can be executed. Run `tan bootstrap` to create the workspace venv.",
1109
+ "tan bootstrap",
1110
+ scope="project",
1111
+ )
1112
+ if not ran:
1113
+ return Check(
1114
+ "westResolved",
1115
+ "fail",
1116
+ f"west resolved to {found}, but it could not be executed there ("
1117
+ f"`{found} --version` did not run to a successful exit) -- no build "
1118
+ f"slice can be executed. A relocated/renamed workspace, or a "
1119
+ f"workspace venv whose own Python was removed, both leave the `west` "
1120
+ f"launcher file in place while breaking it. Run `tan bootstrap` to "
1121
+ f"recreate the workspace venv.",
1122
+ "tan bootstrap",
1123
+ scope="project",
1124
+ )
1125
+ if version is None:
1126
+ return Check("westResolved", "pass", f"west resolved: {found}.", scope="project")
1127
+ return Check("westResolved", "pass", f"west {_fmt(version)} resolved: {found}.", scope="project")
1128
+
1129
+
1130
+ #: tan-cli#1520: the verified remedy for a missing Zephyr SDK toolchain.
1131
+ ZEPHYR_SDK_BOOTSTRAP_FIX = "tan bootstrap"
1132
+
1133
+
1134
+ def zephyr_sdk_install_command() -> str:
1135
+ """The exact `west sdk install` invocation the `zephyrSdk` check's `fix`
1136
+ names -- the ONE place it is assembled, mirroring
1137
+ `tan_core::zephyr_sdk_install_command` verbatim. `tan bootstrap`'s own
1138
+ "Next steps" text (`tan.core.bootstrap`) already promises "the `tan
1139
+ doctor` above reports it, and names the exact install command"; this is
1140
+ what makes that promise true rather than a second, independently-worded
1141
+ copy able to drift from it.
1142
+
1143
+ tan-cli#1520: this is now the README's MANUAL command only. The
1144
+ `zephyrSdk` check and `tan build`'s missing-SDK message deliberately
1145
+ diverge from the Rust oracle and point at `tan bootstrap` instead, because
1146
+ this command's setup.sh fetches the toolchain archive with no integrity
1147
+ check, skipping the alp-sdk sha256 pin (#1501).
1148
+ """
1149
+ return f"west sdk install --version {ZEPHYR_SDK_INSTALL_VERSION} -t arm-zephyr-eabi"
1150
+
1151
+
1152
+ def zephyr_sdk_check(detected: bool, env_dir: str | None = None) -> Check:
1153
+ """`zephyrSdk` -- is the Zephyr SDK cross toolchain (`arm-zephyr-eabi`)
1154
+ actually installed on this host? Ports `tan_core::zephyr_sdk_toolchain_check`
1155
+ / `append_zephyr_sdk_toolchain` (tan-cli#160), closing tan-cli#286: this
1156
+ port had NO such check at all, so on a host with no Zephyr SDK `tan
1157
+ doctor` reported "3 passed, 2 warning(s), 0 failed" and never used the
1158
+ word "toolchain" -- the exact alp-sdk#855 fresh-host gap #160 closed in
1159
+ the Rust oracle, reintroduced here.
1160
+
1161
+ UNCONDITIONAL -- called from `_collect` regardless of `--build`, a
1162
+ `board.yaml`, or an SDK checkout resolving. This is a HOST fact (an env
1163
+ var / a scanned install dir), and ADR 0021 Lane 1 P0a runs `tan doctor`
1164
+ as the very first command a customer runs, before anything project-shaped
1165
+ exists. A Yocto-only project still gets a real `fail` here -- that host
1166
+ genuinely has no Zephyr SDK -- not a skip for lacking a Zephyr core.
1167
+
1168
+ `env_dir` is the raw `ZEPHYR_SDK_INSTALL_DIR` value (or `None`), carried
1169
+ only to word the Fail detail correctly: "ZEPHYR_SDK_INSTALL_DIR unset" is
1170
+ true only when the variable really is unset. It used to be hardcoded even
1171
+ when the variable WAS set and simply named a directory with no working
1172
+ toolchain in it -- the exact stale-var case `_zephyr_sdk_detected` guards
1173
+ against -- so a customer who greps their own environment and finds it set
1174
+ disbelieved a diagnostic that was actually correct.
1175
+
1176
+ Paired with `seven_zip_check` on Windows (`_collect`, gated `os.name ==
1177
+ "nt"` and nothing else -- tan-cli#736 dropped the `!probe.zephyr_sdk` term
1178
+ the Rust sibling had, since the host WITH an SDK and no 7-Zip is precisely
1179
+ what the pairing is for; tan-cli#204 covers that check's SEVERITY, not its
1180
+ gate): the `west sdk install` this Fail's fix names cannot complete on
1181
+ native Windows without 7-Zip on PATH (`manual_install_windows` prose).
1182
+ """
1183
+ if detected:
1184
+ return Check("zephyrSdk", "pass", "Zephyr SDK toolchain detected.", scope="host")
1185
+ where = (
1186
+ f"ZEPHYR_SDK_INSTALL_DIR=`{env_dir}` does not contain a working toolchain"
1187
+ if env_dir
1188
+ else "ZEPHYR_SDK_INSTALL_DIR unset"
1189
+ )
1190
+ # tan-cli#1520: the remedy is `tan bootstrap`, NOT the oracle's
1191
+ # `west sdk install ... -t arm-zephyr-eabi` (deliberate divergence): that
1192
+ # command's setup.sh downloads the toolchain archive with no integrity
1193
+ # check, bypassing the alp-sdk sha256 pin that #1501 made the boundary.
1194
+ # `tan bootstrap` verifies the archive and passes `--no-hosttools`, so the
1195
+ # tan-cli#424 `file(1)` prerequisite note no longer applies to this fix.
1196
+ return Check(
1197
+ "zephyrSdk",
1198
+ "fail",
1199
+ f"Zephyr SDK toolchain not detected ({where}) -- run `{ZEPHYR_SDK_BOOTSTRAP_FIX}`.",
1200
+ f"Install the Zephyr SDK toolchain (arm-zephyr-eabi, version "
1201
+ f"{ZEPHYR_SDK_INSTALL_VERSION}) with `{ZEPHYR_SDK_BOOTSTRAP_FIX}`, which "
1202
+ "verifies the downloaded archive against the alp-sdk pin; a hand-run "
1203
+ "`west sdk install` does not.",
1204
+ scope="host",
1205
+ )
1206
+
1207
+
1208
+ def _toolchain_store_dir(
1209
+ manifest: toolchain_provision.ToolchainManifest,
1210
+ ) -> Path:
1211
+ root = toolchain_provision.resolve_toolchain_root(
1212
+ os.environ.get("ALP_TOOLCHAIN_ROOT"), str(_home_alp_dir())
1213
+ )
1214
+ return Path(root.path_str) / toolchain_provision.store_dir_name(manifest.version)
1215
+
1216
+
1217
+ def _toolchain_root_adopted() -> bool:
1218
+ """`$ALP_TOOLCHAIN_ROOT` is set: tan did not necessarily create that root
1219
+ and `tan bootstrap` never deletes or repairs an unverified store
1220
+ directory in it (tan-cli#1498)."""
1221
+ return toolchain_provision.resolve_toolchain_root(
1222
+ os.environ.get("ALP_TOOLCHAIN_ROOT"), str(_home_alp_dir())
1223
+ ).adopted
1224
+
1225
+
1226
+ def _adopted_store_remedy(store_dir: Path) -> str:
1227
+ return (
1228
+ f"ALP_TOOLCHAIN_ROOT is an adopted root, so `tan bootstrap` will not repair "
1229
+ f"it -- remove {store_dir} yourself (or unset ALP_TOOLCHAIN_ROOT), then run "
1230
+ f"`tan bootstrap`."
1231
+ )
1232
+
1233
+
1234
+ def _host_toolchain_matching_pin(
1235
+ manifest: toolchain_provision.ToolchainManifest,
1236
+ ) -> Path | None:
1237
+ """The already-installed host toolchain root (`_zephyr_sdk_detected_root`
1238
+ -- `ZEPHYR_SDK_INSTALL_DIR` or a scanned `zephyr-sdk*` directory, same
1239
+ precedence `zephyrSdk` above trusts) IF its own `sdk_version` file names
1240
+ the exact version `manifest` pins; else `None`.
1241
+
1242
+ `SDK_VERSION_FILE_RELPATH` is `west sdk install`'s own top-level marker
1243
+ (`toolchain_provision`'s own docstring: measured against a real extracted
1244
+ 1.0.1 tree, exact byte content `"1.0.1\\n"`) -- read back and STRING-
1245
+ compared, matching `toolchain_phase`'s own post-install verification, so
1246
+ this reuses the identical trust boundary rather than a fresh directory-
1247
+ name guess. A missing or unreadable `sdk_version` -- an unusual layout
1248
+ `_zephyr_sdk_root_valid` still validated on compiler-binary presence
1249
+ alone -- is honestly "does not match", not a guessed pass.
1250
+
1251
+ **Never returns an entry inside tan's OWN ADR 0021 store for THIS pin
1252
+ (`_toolchain_store_dir(manifest)`) -- this adoption path is for a
1253
+ toolchain tan did NOT install (tan-cli#990's `~/zephyr-sdk-1.0.1` case),
1254
+ where a version-string match is the only signal available. Since
1255
+ tan-cli#1186 widened `_zephyr_sdk_scan_roots` to also cover that store
1256
+ (for `zephyrSdk`'s sake), `_zephyr_sdk_detected_root` can now return an
1257
+ entry living INSIDE it too -- and that entry is already governed by the
1258
+ stricter, digest-precise `stamp_matches_pin` check `toolchain_check` runs
1259
+ first. A store directory can carry a real compiler and a `sdk_version`
1260
+ matching the pin's nominal version while its stamp names a DIFFERENT
1261
+ digest (the pin moved without the version string changing, ADR 0021's
1262
+ own "stamped 1.0.1 against a moved pin" case) -- measured: without this
1263
+ guard that combination made this function return the store entry as a
1264
+ version-string match, turning the correct stamp-based `fail` a few lines
1265
+ below in `toolchain_check` into a `pass` it never reaches. Trusting the
1266
+ stamp alone for anything inside tan's own store, and never re-deriving a
1267
+ looser verdict for it here, keeps the two checks from disagreeing on the
1268
+ same directory.
1269
+
1270
+ Deliberately keyed on `_toolchain_store_dir(manifest)` -- the one
1271
+ per-version LEAF tan's own installs use -- rather than the broader
1272
+ `_toolchain_store_scan_root()` (the whole configured root, `~/.alp/
1273
+ toolchains` or `$ALP_TOOLCHAIN_ROOT` verbatim). `resolve_toolchain_root`
1274
+ takes an env override VERBATIM as the root, with no `/toolchains`
1275
+ suffix (`toolchain_provision.resolve_toolchain_root`), so ADR 0021's own
1276
+ documented bench/CI escape hatch -- `$ALP_TOOLCHAIN_ROOT` pointed at an
1277
+ ancestor like `$HOME` or `/opt` -- makes the store root coincide with a
1278
+ directory a hand-installed host toolchain also legitimately lives under.
1279
+ Excluding the whole root there would misread that adopted toolchain as
1280
+ "inside tan's own store" and refuse to adopt it, turning a
1281
+ previously-passing configuration into a `toolchain` FAIL; excluding only
1282
+ the specific `<root>/<store leaf for this manifest version>` directory
1283
+ keeps the guard narrow enough to still hold under that escape hatch.
1284
+ """
1285
+ try:
1286
+ store = _toolchain_store_dir(manifest).resolve()
1287
+ except OSError:
1288
+ store = None
1289
+ for root in _zephyr_sdk_detected_roots():
1290
+ try:
1291
+ if store is not None and root.resolve().is_relative_to(store):
1292
+ continue
1293
+ except OSError:
1294
+ pass
1295
+ version_text = _read_text(root / toolchain_provision.SDK_VERSION_FILE_RELPATH)
1296
+ if version_text is not None and version_text.strip() == manifest.version:
1297
+ return root
1298
+ return None
1299
+
1300
+
1301
+ def toolchain_check(sdk_root: str | None) -> Check:
1302
+ """`toolchain` -- STAMP-vs-PIN, never directory-exists (issue #474, ADR
1303
+ 0021 Lane 1 P1's own words: "a stamped 1.0.1 store against a moved pin is
1304
+ a Fail with a fix, not 'a toolchain exists'"). Calls
1305
+ `toolchain_provision.stamp_matches_pin` -- the SAME function `tan
1306
+ bootstrap`'s own skip-if-already-installed step calls -- so this check
1307
+ and `bootstrap` cannot independently drift on what "still valid" means,
1308
+ the same `zephyr_python_floor`-is-imported-not-re-derived pattern this
1309
+ file already applies one layer up.
1310
+
1311
+ Distinct from `zephyrSdk` above, which answers "does ANY working
1312
+ toolchain exist on this host" unconditionally, with no SDK checkout
1313
+ needed (an env var or a scanned install dir). This one answers a
1314
+ narrower, PIN-specific question -- "does THIS checkout's pinned
1315
+ cross-toolchain version exist here, verified" -- so it needs `sdk_root`
1316
+ and reports `unknown` without one: there is no pin to check against.
1317
+
1318
+ `scope="project"` on every arm (never `"host"`, unlike `zephyrSdk`): a
1319
+ single check NAME may not carry two scopes (`tests/gates/
1320
+ test_doctor_check_scope.py`), and this one is inherently
1321
+ project-scoped -- it cannot answer at all without a resolved
1322
+ `sdk_root` to read `metadata/toolchains.json`'s PIN from.
1323
+
1324
+ **Has an adoption path (tan-cli#990 review, the tan-cli#299
1325
+ false-refusal class): an unstamped store is not the only way to satisfy
1326
+ this check.** Before either `fail` branch, `_host_toolchain_matches_pin`
1327
+ asks whether a toolchain THIS HOST ALREADY HAS -- found by the SAME
1328
+ `ZEPHYR_SDK_INSTALL_DIR`/scan `zephyrSdk` above already trusts -- reports
1329
+ the pinned version in ITS OWN `sdk_version` file. If so this is a `pass`,
1330
+ never a `fail`: a stamp is tan's own bookkeeping for what IT installed,
1331
+ not the only proof a working, correctly-versioned toolchain exists. Before
1332
+ this, a host with a hand-installed or pre-existing SDK at the exact pinned
1333
+ version -- `zephyrSdk` passing right above it -- got a `toolchain` FAIL
1334
+ whose only prescribed fix, `tan bootstrap`, downloads a SECOND ~1.9 GiB
1335
+ copy of a toolchain already on disk, and cannot even do that on an
1336
+ offline host or the README's own hand-`west sdk install` path (this
1337
+ check's one stamp-writing producer is `tan bootstrap` alone).
1338
+ """
1339
+ if sdk_root is None:
1340
+ return Check(
1341
+ "toolchain",
1342
+ "unknown",
1343
+ "no alp-sdk checkout resolved -- cannot read metadata/toolchains.json to "
1344
+ "know which cross-toolchain version is pinned.",
1345
+ scope="project",
1346
+ )
1347
+ manifest_path = Path(sdk_root) / "metadata" / "toolchains.json"
1348
+ text = _read_text(manifest_path)
1349
+ if text is None:
1350
+ return Check(
1351
+ "toolchain",
1352
+ "unknown",
1353
+ f"{manifest_path} is missing or unreadable -- cannot determine the "
1354
+ f"pinned cross-toolchain version.",
1355
+ scope="project",
1356
+ )
1357
+ try:
1358
+ manifest = toolchain_provision.parse_toolchain_manifest(text)
1359
+ except toolchain_provision.ToolchainManifestError as err:
1360
+ return Check(
1361
+ "toolchain", "unknown", f"{manifest_path} is malformed: {err}", scope="project",
1362
+ )
1363
+ store_dir = _toolchain_store_dir(manifest)
1364
+ stamp_text = _read_text(store_dir / toolchain_provision.STAMP_FILENAME)
1365
+ stamp = toolchain_provision.parse_stamp(stamp_text) if stamp_text is not None else None
1366
+ adopted = _toolchain_root_adopted()
1367
+ if toolchain_provision.stamp_matches_pin(stamp, manifest):
1368
+ if not toolchain_provision.store_compiler_present(store_dir, is_windows=os.name == "nt"):
1369
+ if adopted:
1370
+ return Check(
1371
+ "toolchain",
1372
+ "fail",
1373
+ f"{store_dir} carries a verification stamp for {manifest.version} "
1374
+ f"but its arm-zephyr-eabi-gcc is gone -- "
1375
+ f"{_adopted_store_remedy(store_dir)}",
1376
+ scope="project",
1377
+ )
1378
+ return Check(
1379
+ "toolchain",
1380
+ "fail",
1381
+ f"{store_dir} carries a verification stamp for {manifest.version} but "
1382
+ f"its arm-zephyr-eabi-gcc is gone -- run `tan bootstrap` to repair it.",
1383
+ "tan bootstrap",
1384
+ scope="project",
1385
+ )
1386
+ return Check(
1387
+ "toolchain", "pass",
1388
+ f"arm-zephyr-eabi {manifest.version} installed at {store_dir}; version and "
1389
+ f"compiler were checked at install, but "
1390
+ f"{toolchain_provision.ARCHIVE_SHA256_NOTE}"
1391
+ + ("." if stamp is not None and stamp.pin_checked else " (this install predates the check)."),
1392
+ scope="project",
1393
+ )
1394
+ host_root = _host_toolchain_matching_pin(manifest)
1395
+ if host_root is not None:
1396
+ return Check(
1397
+ "toolchain",
1398
+ "pass",
1399
+ f"arm-zephyr-eabi {manifest.version} detected at {host_root} -- not tan's "
1400
+ f"own store, but its `sdk_version` matches this checkout's pin, so this "
1401
+ f"host already has what `tan bootstrap` would otherwise download again. "
1402
+ f"That match is the `sdk_version` text file only: this install was never "
1403
+ f"compared with the pin (its archive may have been fetched unchecked); "
1404
+ f"{toolchain_provision.ARCHIVE_SHA256_NOTE}.",
1405
+ scope="project",
1406
+ )
1407
+ if stamp is not None:
1408
+ if adopted:
1409
+ return Check(
1410
+ "toolchain",
1411
+ "fail",
1412
+ f"{store_dir} carries a verification stamp for a different pin (this "
1413
+ f"checkout now pins {manifest.version}) -- "
1414
+ f"{_adopted_store_remedy(store_dir)}",
1415
+ scope="project",
1416
+ )
1417
+ return Check(
1418
+ "toolchain",
1419
+ "fail",
1420
+ f"{store_dir} carries a verification stamp for a different pin (this "
1421
+ f"checkout now pins {manifest.version}) -- run `tan bootstrap` to "
1422
+ f"acquire the current pin.",
1423
+ "tan bootstrap",
1424
+ scope="project",
1425
+ )
1426
+ if adopted and store_dir.is_dir():
1427
+ return Check(
1428
+ "toolchain",
1429
+ "fail",
1430
+ f"no verified arm-zephyr-eabi {manifest.version} toolchain at {store_dir} "
1431
+ f"-- {_adopted_store_remedy(store_dir)}",
1432
+ scope="project",
1433
+ )
1434
+ return Check(
1435
+ "toolchain",
1436
+ "fail",
1437
+ f"no verified arm-zephyr-eabi {manifest.version} toolchain at {store_dir} -- "
1438
+ f"run `tan bootstrap` to acquire it (ADR 0021 Lane 1 P1).",
1439
+ "tan bootstrap",
1440
+ scope="project",
1441
+ )
1442
+
1443
+
1444
+ def devicetree_lint_check(resolution: devicetree_lint.DtcResolution) -> Check:
1445
+ """`devicetreeLint` -- will Zephyr's diagnostics-only `dtc` pass actually
1446
+ run for a build on this host (tan-cli#1192)?
1447
+
1448
+ See `tan.core.devicetree_lint` for the two CMake mechanisms this mirrors
1449
+ (`find_program(DTC dtc)` over the SDK's `hosttools` prefix then `PATH`,
1450
+ then `find_package(Dtc 1.4.6)`'s silent `DTC-NOTFOUND` reset) and for the
1451
+ tan-cli#1176/#1178 premise change that made the question worth asking.
1452
+
1453
+ **This partially reverses this module's own recorded decision that
1454
+ `dtc`/`gperf` were deliberately NOT ported from the frozen Rust oracle's
1455
+ `--build` vocabulary** (see the module docstring). That decision was right
1456
+ when it was made and its premise is gone: `dtc` used to arrive with the
1457
+ Zephyr SDK on every install, so a `dtc` probe could only restate what
1458
+ `zephyrSdk` already reported. Since tan-cli#1178 `tan bootstrap` passes
1459
+ `--no-hosttools`, the SDK ships no `dtc`, and the lint pass stopped
1460
+ running with nothing anywhere saying so. `gperf` stays unported; only
1461
+ `dtc` is reopened, and only as this question.
1462
+
1463
+ **The severity, which is the whole difficulty.** A naive "is `dtc` on
1464
+ PATH" `warn` would fire on EVERY correctly-bootstrapped host, which is the
1465
+ anti-pattern `west_check`'s own docstring records ("a warning that fires
1466
+ on every correct install trains users to ignore warnings", tan-cli#299).
1467
+ So the no-`dtc` arm is a `pass`: it is the ordinary, supported, documented
1468
+ state, the build genuinely succeeds, and `tan bootstrap` is not a remedy
1469
+ for it -- bootstrap is what caused it, on purpose. The row still SAYS the
1470
+ lint is not running, which is the visibility tan-cli#1192 asks for, but it
1471
+ raises no `issues[]` entry, contributes no `nextSteps` line, lands in
1472
+ `summary.pass`, and cannot move the exit code.
1473
+
1474
+ The one `warn` arm is the host where somebody INSTALLED a `dtc` and does
1475
+ not get the lint anyway, because `FindDtc.cmake` judged it unusable and
1476
+ reset it to `DTC-NOTFOUND` without printing anything. That is a real,
1477
+ actionable divergence between what an operator did and what they got, it
1478
+ is unreachable on a host provisioned per this repo's own onramp, and its
1479
+ fix is concrete. `warn`, never `fail`: `exit_code_for` reserves exit 4 for
1480
+ a host that cannot build, and this one builds fine -- it just builds
1481
+ without a class of diagnostics.
1482
+
1483
+ NOT harvested by `tan support-bundle`: that command reports only the five
1484
+ HOST checks `support_bundle_cmd._HOST_CHECK_ORDER` names, built by
1485
+ `host_environment_checks` (which this is deliberately not added to), so
1486
+ this check reaches neither that command's report nor its verdict. That is
1487
+ the right call for the same reason `_demote_long_paths_fail` exists --
1488
+ support-bundle's acceptance bar is matching the frozen oracle's axes, and
1489
+ the oracle has no devicetree-lint axis at all.
1490
+
1491
+ `scope="host"` on every arm: the subject is this machine's tooling (an SDK
1492
+ install and `PATH`), identical for every project built on it, which is
1493
+ `tan.core.doctor_scope`'s own test.
1494
+ """
1495
+ floor = devicetree_lint.format_version(devicetree_lint.DTC_MIN_VERSION)
1496
+ lint_flags = "`-E unit_address_vs_reg` / `-Wunique_unit_address_if_enabled`"
1497
+ if devicetree_lint.lint_will_run(resolution) and resolution.version is not None:
1498
+ where = (
1499
+ "the Zephyr SDK's own hosttools bundle, which `host-tools.cmake` puts on "
1500
+ "`CMAKE_PREFIX_PATH`"
1501
+ if resolution.origin == devicetree_lint.ORIGIN_HOSTTOOLS
1502
+ else "this host's PATH"
1503
+ )
1504
+ return Check(
1505
+ "devicetreeLint",
1506
+ "pass",
1507
+ f"dtc {devicetree_lint.format_version(resolution.version)} at "
1508
+ f"{resolution.path} ({where}) -- Zephyr's devicetree lint pass "
1509
+ f"({lint_flags}, `dts.cmake`'s `if(DTC)` block) runs for builds here.",
1510
+ scope="host",
1511
+ )
1512
+ if resolution.path is not None:
1513
+ answered = (
1514
+ f"reports {devicetree_lint.format_version(resolution.version)}, below the "
1515
+ f"{floor} floor"
1516
+ if resolution.version is not None
1517
+ else "did not answer `dtc --version` in a form `FindDtc.cmake` can parse"
1518
+ )
1519
+ return Check(
1520
+ "devicetreeLint",
1521
+ "warn",
1522
+ f"`dtc` is installed at {resolution.path} but {answered}, so Zephyr's "
1523
+ f"`find_package(Dtc {floor})` rejects it and `FindDtc.cmake` resets DTC to "
1524
+ f"`DTC-NOTFOUND` -- silently. The devicetree lint pass ({lint_flags}) does "
1525
+ f"not run, exactly as if no dtc were installed, and nothing in the build "
1526
+ f"output says so.",
1527
+ f"Install dtc {floor} or newer (Debian/Ubuntu `device-tree-compiler`, "
1528
+ f"Homebrew `dtc`) ahead of {resolution.path} on PATH, or drop this one so "
1529
+ f"the report stops claiming a lint that is not happening.",
1530
+ scope="host",
1531
+ )
1532
+ return Check(
1533
+ "devicetreeLint",
1534
+ "pass",
1535
+ f"no `dtc` in CMake's reach (neither a Zephyr SDK `hosttools/` copy nor one on "
1536
+ f"PATH), so Zephyr's devicetree lint pass ({lint_flags}) is SKIPPED -- "
1537
+ f"`dts.cmake` guards it with `if(DTC)` and treats dtc as optional. Builds "
1538
+ f"succeed and produce a real `zephyr.elf`; only those diagnostics are absent. "
1539
+ f"This is the expected state after `tan bootstrap`, which acquires the Zephyr "
1540
+ f"SDK with `--no-hosttools` (tan-cli#1178) and so installs no dtc. Install a "
1541
+ f"system dtc {floor} or newer if you want the lint back.",
1542
+ scope="host",
1543
+ )
1544
+
1545
+
1546
+ def seven_zip_check(found: bool) -> Check:
1547
+ """`sevenZip` -- Windows-only, unconditionally (tan-cli#736 dropped the
1548
+ `zephyrSdk`-is-failing half). Ports the Rust oracle's sibling (`crate::
1549
+ build_readiness`, tan-cli#204): `west sdk install`, the remedy
1550
+ `zephyr_sdk_check` names, extracts the `.7z` toolchain archive by
1551
+ delegating to `patoolib`, which shells out to one of `SEVEN_ZIP_PROGRAMS`
1552
+ and has no pure-Python fallback -- documented in this repo's own
1553
+ `tan.core.bootstrap` (`manual_install_windows` prose) but, until this
1554
+ check, reaching no JSON consumer, so `alp-sdk-vscode` had no way to
1555
+ surface it and a customer who followed the `zephyrSdk` fix hint alone hit
1556
+ a patoolib error naming no Alp surface and no mention of 7-Zip.
1557
+
1558
+ `Warn`, not `Fail`, mirroring the oracle: missing 7-Zip blocks the
1559
+ REMEDY, not the build itself -- `zephyrSdk` is the `Fail` that stops
1560
+ things. That reasoning is unchanged.
1561
+
1562
+ What DID change (tan-cli#736) is the gate this check used to ride: it
1563
+ ran only alongside a FAILING `zephyrSdk`, on the premise that "a host
1564
+ that already has the SDK never reaches this". That premise does not
1565
+ hold -- "already has the SDK" is not "will never run `west sdk
1566
+ install` again". Adding a second architecture's toolchain, or moving to
1567
+ a newer SDK, is an ordinary operation; `zephyrSdk` passes throughout,
1568
+ and the check never fired. Measured on Windows with `7z` stripped from
1569
+ PATH and the SDK present: `overall ok: True`, and
1570
+ `any check mentioning 7z: []` -- a green verdict on a host that cannot
1571
+ complete `west sdk install`. It now runs unconditionally on Windows.
1572
+ """
1573
+ if found:
1574
+ return Check(
1575
+ "sevenZip",
1576
+ "pass",
1577
+ "7-Zip is available -- `west sdk install` can extract the toolchain.",
1578
+ scope="host",
1579
+ )
1580
+ programs = ", ".join(SEVEN_ZIP_PROGRAMS)
1581
+ return Check(
1582
+ "sevenZip",
1583
+ "warn",
1584
+ f"No 7-Zip on PATH (looked for {programs}) -- `west sdk install` extracts "
1585
+ "the toolchain with patoolib, which shells out to one of these and has no "
1586
+ "pure-Python fallback, so it will fail on native Windows. Install it with "
1587
+ f"`{SEVEN_ZIP_INSTALL_COMMAND}`.",
1588
+ f"Install 7-Zip before running `west sdk install`: `{SEVEN_ZIP_INSTALL_COMMAND}`.",
1589
+ scope="host",
1590
+ )
1591
+
1592
+
1593
+ def zephyr_workspace_check(workspace_dir: str, version_text: str | None) -> Check:
1594
+ """`zephyrWorkspace` -- unconditional now, not `--build`-only
1595
+ (tan-cli#290): does the RESOLVED workspace's `zephyr/` subtree actually
1596
+ look like a Zephyr checkout at all?
1597
+
1598
+ `workspace_dir`/`version_text` are the SAME
1599
+ `tan.core.venv.west_workspace_dir`-resolved facts `workspace`/
1600
+ `zephyrVersion` above already compute -- not a second, independent
1601
+ `$ZEPHYR_BASE` env-var read, which was tan-cli#294's own complaint about
1602
+ this check ("probes an env var, not the resolved topdir"). Callers only
1603
+ reach this once a workspace has actually resolved: `workspace` above
1604
+ already fails outright on a totally-absent one, and re-warning that same
1605
+ absence here under a second name would be exactly the one-fact-twice
1606
+ duplication this file's `boardYaml` handling (mirroring the Rust oracle)
1607
+ already refuses to do -- so there is no "unresolved" branch here at all.
1608
+
1609
+ **No Fail branch (tan-cli#295 review, reversing tan-cli#290's own
1610
+ addition of one).** A version-mismatch Fail was added to mirror Rust's
1611
+ `crates/tan-core/src/preflight.rs:118-145` (tan-cli#98/#159, the
1612
+ alp-sdk#855 v4.4.0->v4.4.1 incident, where a drifted checkout reported
1613
+ `11 passed, 6 warnings, 0 failed` and the very next build broke) -- but
1614
+ `zephyr_version_preflight_check` above already reports that identical
1615
+ fact, from these identical two inputs (`workspace_version`/`sdk_pin`), at
1616
+ Fail severity. This check's would-be Fail condition was a strict SUBSET
1617
+ of that one, so it could never fire without `zephyrVersion` having
1618
+ already reported it under a different code: measured on a drifted host,
1619
+ `summary.fail` came out 5 instead of 4, both `doctor.zephyrVersion` and
1620
+ `doctor.zephyrWorkspace` present, and two `nextSteps` strings for the one
1621
+ `tan bootstrap` remedy. Removed rather than kept "in step" with it --
1622
+ Rust's own `crates/tan-cli/src/commands/doctor.rs` drops its comparable
1623
+ `boardYaml` duplicate for the identical reason ("emitting both would
1624
+ report one fact twice"), and `grep -rn "zephyrWorkspace" crates/` is
1625
+ empty: there is no Rust oracle row here for a version-mismatch Fail to
1626
+ stay parallel with.
1627
+
1628
+ An unreadable `zephyr/VERSION` stays `Warn`: neither the Rust oracle nor
1629
+ `zephyr_version_preflight_check` above (which silently SKIPS rather than
1630
+ fails when the version is unknown -- "don't nag when this cannot
1631
+ actually be verified") treats this as more than that, and a resolved
1632
+ `.west` workspace mid-`west update` -- `zephyr/` not yet cloned -- is a
1633
+ legitimate, working-in-progress host state, not a proven blocker. This is
1634
+ the one fact `zephyrVersion` cannot see at all (it skips outright), so it
1635
+ is this check's whole remaining reason to exist.
1636
+ """
1637
+ if version_text is None:
1638
+ return Check(
1639
+ "zephyrWorkspace",
1640
+ "warn",
1641
+ f"workspace at `{workspace_dir}` does not look like a Zephyr checkout "
1642
+ f"(no readable zephyr/VERSION file).",
1643
+ "Run `tan bootstrap`, or point the workspace at a real Zephyr checkout.",
1644
+ scope="project",
1645
+ )
1646
+ return Check(
1647
+ "zephyrWorkspace", "pass", f"Zephyr {version_text} at `{workspace_dir}`.",
1648
+ scope="project",
1649
+ )
1650
+
1651
+
1652
+ def jlink_available(app_dir: str, sdk_root: str | None) -> bool:
1653
+ """Whether `tan flash` would find a J-Link tool: the SAME test it applies
1654
+ (`flash_cmd._tool_available` -- PATH or the workspace venv)."""
1655
+ try:
1656
+ venv_bin = venv_bin_dir(app_dir, sdk_root)
1657
+ return any(_tool_available(n, venv_bin) for n in ("JLinkExe", "JLink"))
1658
+ except Exception: # noqa: BLE001
1659
+ return False
1660
+
1661
+
1662
+ def setools_check(
1663
+ setools_dir: str | None,
1664
+ se_uart: str | None,
1665
+ is_linux: bool,
1666
+ flash_methods: frozenset[str] | None = None,
1667
+ jlink_found: bool | None = None,
1668
+ setools_source: str = "$SETOOLS_DIR",
1669
+ ) -> Check:
1670
+ """`setools` -- can this host flash an Alif AEN part's MRAM at all?
1671
+
1672
+ Nothing else in either doctor asks. `scripts/west_commands/runners/
1673
+ alif_flash.py` raises a bare `RuntimeError` for each of these the moment a
1674
+ customer runs `west flash`, so the first time they learn is at the bench.
1675
+
1676
+ WARN, not FAIL: this is one flow, on one SoM family. A customer building for
1677
+ a V2N or native_sim never touches it, and a `fail` here would exit 4 on a
1678
+ perfectly healthy host. `unknown` off Linux -- `alif_flash.py` hard-codes
1679
+ `app-release-exec-linux`, so there is no verdict to give a native
1680
+ Windows/macOS host, and `unknown` is counted in no summary bucket.
1681
+
1682
+ **No longer probes any interpreter for `fdt` (tan-cli#641).** It used to,
1683
+ most recently against the workspace venv's own Python (tan-cli#488 defect
1684
+ 6). Measured on real AEN silicon: `fdt` was genuinely unimportable in the
1685
+ workspace venv while `app-gen-toc` -- which `tan.core.setools.sign_slot0`
1686
+ spawns as its own subprocess, never as a Python import under any
1687
+ interpreter this command could probe, and which SETOOLS ships its own
1688
+ dependencies for regardless -- signed successfully, and the resulting MRAM
1689
+ write persisted a cold power cycle. Probing one interpreter's `sys.path`
1690
+ was never going to answer whether a flash that spawns a separate,
1691
+ license-gated binary will succeed, and the false alarm it produced here
1692
+ trains an operator to stop trusting doctor on the one question they ask it
1693
+ before a write.
1694
+
1695
+ **Method-aware (tan-cli#1323).** `$SE_UART` belongs to Flow A
1696
+ (`zephyr_west_flash` -> the `alif_flash` runner) ONLY. A planner-emitted
1697
+ AEN manifest dispatches Flow D (`alif_mram_jlink`): SETOOLS signs the ATOC
1698
+ (`app-gen-toc`) and J-Link writes it over SWD, no SE-UART. `flash_methods`
1699
+ is the set `tan flash` would dispatch for the project's built manifest
1700
+ (`tan.core.doctor_setools.project_flash_methods`, which calls
1701
+ `select_flash_method` itself); `None` means no project/manifest is in
1702
+ scope, and the verdict is then phrased per method rather than asserting
1703
+ one. `jlink_found` is whether a J-Link tool is on PATH (Flow D only).
1704
+ """
1705
+ flow_d = flash_methods is not None and FLOW_D_METHOD in flash_methods
1706
+ flow_a = flash_methods is not None and FLOW_A_METHOD in flash_methods
1707
+ if flash_methods is not None and not (flow_a or flow_d):
1708
+ return Check(
1709
+ "setools",
1710
+ "unknown",
1711
+ "this project's slices flash via "
1712
+ f"{', '.join(f'`{m}`' for m in sorted(flash_methods)) or 'no method'}, "
1713
+ "which does not use SETOOLS -- nothing to check here.",
1714
+ scope="host",
1715
+ )
1716
+ if flow_a and not flow_d and not is_linux and not setools_dir and not se_uart:
1717
+ return Check(
1718
+ "setools",
1719
+ "unknown",
1720
+ "AEN MRAM flashing over the SE-UART is Linux-only in this tree: "
1721
+ "scripts/west_commands/runners/alif_flash.py hard-codes "
1722
+ f"`app-release-exec-linux`, so its bundle is `{SETOOLS_BUNDLE}`. "
1723
+ "Nothing to check on this host for THAT path -- run it from "
1724
+ "WSL2/Linux (Windows hosts pass the SE-UART through with usbipd). "
1725
+ "Flow D below is a different matter: it signs on Windows with the "
1726
+ "WINDOWS SETOOLS build, which is a different bundle from the "
1727
+ "`-linux` one named above -- point `--setools-dir` at it (measured: "
1728
+ "`app-gen-toc.exe` under a Windows `app-release-exec`, "
1729
+ "SETOOLS_version_SE_FW_1.110.00_DEV, signed an ATOC the part "
1730
+ "booted from).",
1731
+ scope="host",
1732
+ )
1733
+
1734
+ status, detail, fix = setools_verdict(
1735
+ setools_dir,
1736
+ setools_source,
1737
+ se_uart,
1738
+ flash_methods,
1739
+ jlink_found,
1740
+ SETOOLS_BUNDLE,
1741
+ SETOOLS_EXECUTABLES,
1742
+ is_linux,
1743
+ )
1744
+ return Check("setools", status, detail, fix, scope="host")
1745
+
1746
+
1747
+ def jlink_banner(jlink_exe: str, timeout: int = PROBE_TIMEOUT_S) -> str | None:
1748
+ """The version banner JLinkExe prints, unprompted, at the top of EVERY
1749
+ invocation -- `SEGGER J-Link Commander V9.50 (Compiled ...)` / `DLL
1750
+ version V9.50` -- before it ever tries to reach a probe or a target.
1751
+
1752
+ **Not `[jlink_exe, "-?"]` (tan-cli#641).** JLinkExe has no such flag:
1753
+ `JLinkExe -?` answers `Unknown command line option -?.` and exits
1754
+ non-zero, so the old probe never once reached the banner it was trying to
1755
+ read -- measured on real AEN silicon, this was the whole reason a J-Link
1756
+ tool that answers `V9.50` unprompted on every run still reported "version
1757
+ could not be read".
1758
+
1759
+ Feeds `exit\\n` on stdin so Commander quits at its own interactive prompt
1760
+ rather than sitting there waiting for input or a probe that may not be
1761
+ plugged in; `timeout` still bounds it regardless. Unlike `probe()`, this
1762
+ reads stdout REGARDLESS of the exit code -- the banner is already printed
1763
+ by the time Commander decides how it exits (e.g. because no emulator is
1764
+ attached), and that text must not be discarded along with a non-zero
1765
+ status that says nothing about whether the banner itself was readable.
1766
+
1767
+ Commander-only contract: this assumes `jlink_exe` is `JLinkExe`/`JLink`,
1768
+ which quit on the `exit\\n` fed to them. `JLinkGDBServerCL` does not read
1769
+ stdin at all -- it waits for a GDB connection and holds its port for the
1770
+ full `timeout` regardless of what is piped in. The caller (`_collect`)
1771
+ must not spawn this against that binary; see its comment there.
1772
+
1773
+ `-NoGui 1` matches every other J-Link spawn in this repo
1774
+ (`flash_plan.py`, `flash_cmd.py`) -- without it a GUI-capable install can
1775
+ raise a modal dialog (firmware-update / device-select) that `exit\\n` on
1776
+ stdin does not dismiss, blocking for the full `timeout` regardless.
1777
+ """
1778
+ try:
1779
+ completed = subprocess.run(
1780
+ [jlink_exe, "-NoGui", "1"],
1781
+ input="exit\n",
1782
+ capture_output=True,
1783
+ text=True,
1784
+ encoding="utf-8",
1785
+ errors="replace",
1786
+ timeout=timeout,
1787
+ env=spawn_env(),
1788
+ check=False,
1789
+ )
1790
+ except (OSError, ValueError, subprocess.SubprocessError):
1791
+ return None
1792
+ return completed.stdout or None
1793
+
1794
+
1795
+ def jlink_check(
1796
+ found: str | None,
1797
+ version: tuple[int, int] | None,
1798
+ device: str = JLINK_AEN_DEVICE,
1799
+ device_source: str | None = None,
1800
+ ) -> Check:
1801
+ """`jlink` -- Flow D, the day-to-day burn path (J-Link direct MRAM flash over
1802
+ SWD, ~0.16 s, no SE-UART).
1803
+
1804
+ Three facts a presence check alone would hide, so all three travel in the
1805
+ message even when the binary is there: the loader is built into the J-Link
1806
+ DLL from V9.46 (nothing separate to install, and nothing at all below it),
1807
+ it is unlocked ONLY by the part-number device profile -- the generic
1808
+ `Cortex-M55` connects fine and has no MRAM loader, so a burn against it
1809
+ silently is not one. Both are unprobeable from the host, which is exactly
1810
+ why they must be said.
1811
+
1812
+ tan-cli#739: a third "fact" used to travel with them -- that the probe
1813
+ needs matched J-Link V13 firmware or the part-number device will not
1814
+ connect. Measured on the AEN EVK, that is false. A probe reporting
1815
+ `Firmware: J-Link V11 compiled Apr 1 2025 10:02:30` / `Hardware version:
1816
+ V11.00` connected WITH the part-number profile and programmed MRAM
1817
+ repeatedly, including a 96 KiB loadbin + verifybin at 0x80560000 that
1818
+ byte-verified (alp-sdk#1380 SS1). The claim sent customers with a working
1819
+ probe to a firmware update they did not need, and read as "your setup is
1820
+ unsupported" on a setup that demonstrably works.
1821
+
1822
+ It is NOT restated as "V11+". Where the real floor sits is unknown -- no
1823
+ probe old enough to fail has been tested -- and swapping one unmeasured
1824
+ minimum for another is the same defect wearing a different number. The
1825
+ firmware clause is dropped entirely; the DLL floor and the device profile
1826
+ are the two requirements that are actually established.
1827
+
1828
+ `device` defaults to `JLINK_AEN_DEVICE` so every existing call site keeps
1829
+ working; `_collect` passes the metadata-resolved value from
1830
+ `jlink_flash_device` instead, when an SDK checkout resolved one.
1831
+
1832
+ `device_source` (also from `jlink_flash_device`) is surfaced into the
1833
+ detail text when given, so the same `device` string is not byte-identical
1834
+ whether it came from a resolved SDK checkout or tan's built-in fallback --
1835
+ otherwise a user on a host where the SDK did not resolve has no way to
1836
+ tell which one they are looking at.
1837
+ """
1838
+ requirements = (
1839
+ f"Flow D needs the `{device}` part-number device profile (NOT the "
1840
+ f"generic `Cortex-M55`, which has no MRAM loader) and a J-Link DLL "
1841
+ f"V{_fmt(JLINK_MIN_DLL)}+."
1842
+ )
1843
+ if device_source is not None:
1844
+ requirements += f" Device profile resolved from: {device_source}."
1845
+ if found is None:
1846
+ return Check(
1847
+ "jlink",
1848
+ "warn",
1849
+ "SEGGER J-Link tools are not on PATH (optional -- needed for Flow D "
1850
+ "MRAM flash and SWD debug, not for native_sim or SE-UART flashing). "
1851
+ + requirements,
1852
+ "Install the SEGGER J-Link Software & Documentation Pack "
1853
+ f"(V{_fmt(JLINK_MIN_DLL)} or newer).",
1854
+ scope="host",
1855
+ )
1856
+ if version is None:
1857
+ return Check(
1858
+ "jlink",
1859
+ "warn",
1860
+ f"J-Link tools found at {found} but their version could not be read, so "
1861
+ f"the Flow D MRAM loader could not be confirmed. " + requirements,
1862
+ "Run `JLinkExe` by hand (it prints the banner unprompted, then type "
1863
+ "`exit`) and confirm it reports "
1864
+ f"V{_fmt(JLINK_MIN_DLL)} or newer.",
1865
+ scope="host",
1866
+ )
1867
+ if version < JLINK_MIN_DLL:
1868
+ return Check(
1869
+ "jlink",
1870
+ "warn",
1871
+ f"J-Link V{_fmt(version)} ({found}) predates V{_fmt(JLINK_MIN_DLL)}, which "
1872
+ f"is where Alif's MRAM flash loader became built in -- Flow D has nothing "
1873
+ f"to program MRAM with on this DLL. " + requirements,
1874
+ f"Upgrade the SEGGER J-Link pack to V{_fmt(JLINK_MIN_DLL)}+.",
1875
+ scope="host",
1876
+ )
1877
+ return Check(
1878
+ "jlink", "pass", f"J-Link V{_fmt(version)} ({found}). " + requirements,
1879
+ scope="host",
1880
+ )
1881
+
1882
+
1883
+ # ---------------------------------------------------------------------------
1884
+ # Host-environment checks (tan-cli#294 finding 1, reintroducing tan-cli#70).
1885
+ #
1886
+ # `zephyr_sdk_check` above only answers "is a Zephyr SDK installed HERE" --
1887
+ # never "CAN one be installed on this machine at all". A Windows-on-ARM or
1888
+ # Intel-Mac host is served by neither a native Zephyr SDK build nor (on
1889
+ # macOS) a WSL2 fallback, and `zephyrSdkAvailableForHost` below is the ONLY
1890
+ # check that says so; `zephyrSdk`'s Fail just points at a `west sdk install`
1891
+ # that can never complete there. Unconditional, like `zephyr_sdk_check`: a
1892
+ # HOST fact needing no board.yaml/workspace/SDK, so it runs on plain
1893
+ # `tan doctor` (ADR 0021 Lane 1 P0a runs that BEFORE anything project-shaped
1894
+ # exists).
1895
+ # ---------------------------------------------------------------------------
1896
+
1897
+
1898
+ def zephyr_sdk_host_check(host_os: str, arch: str) -> Check:
1899
+ """`zephyrSdkAvailableForHost` -- mirrors
1900
+ `tan_core::host_env::zephyr_sdk_host_check` byte-for-byte, including the
1901
+ two DIFFERENT remedies for the two unserved hosts: a Windows-on-ARM host
1902
+ has a first-class route (WSL2, which reports as the served
1903
+ `linux-aarch64`), a macOS host does not (Rosetta translates x86_64 FOR
1904
+ Apple silicon, not the reverse, and there is no WSL2 equivalent) --
1905
+ collapsing the two into one message would send an Intel Mac owner
1906
+ chasing a `wsl --install` that does not exist on their OS.
1907
+
1908
+ `Fail`, not `Warn`: this is the one check in the trio that means "the
1909
+ toolchain cannot run here at all", the same category as a missing
1910
+ `ninja` (`hostPrerequisites`'s own `Fail`) -- there is no artifact for
1911
+ `west sdk install` to fetch, and no amount of PATH or workspace fixing
1912
+ changes that.
1913
+ """
1914
+ tag = f"{host_os}-{arch}"
1915
+ if tag in ZEPHYR_SDK_HOSTS:
1916
+ return Check(
1917
+ "zephyrSdkAvailableForHost",
1918
+ "pass",
1919
+ f"The Zephyr SDK publishes a host build for {tag}.",
1920
+ scope="host",
1921
+ )
1922
+ served = ", ".join(ZEPHYR_SDK_HOSTS)
1923
+ if tag == "windows-aarch64":
1924
+ detail = (
1925
+ f"Windows on ARM ({tag}, `windows-arm64` in Zephyr's own naming): the Zephyr "
1926
+ f"SDK has never published a host build for it. Served hosts are {served}. A "
1927
+ "native Windows build cannot be provisioned on this machine."
1928
+ )
1929
+ fix = (
1930
+ "Build inside WSL2 instead: install a WSL2 Linux distribution "
1931
+ "(`wsl --install`), then run `tan bootstrap` and `tan build` from inside it -- "
1932
+ "a WSL2 distro on this hardware is linux-aarch64, which the Zephyr SDK does "
1933
+ "publish."
1934
+ )
1935
+ elif tag == "macos-x86_64":
1936
+ detail = (
1937
+ f"Intel Mac ({tag}): the Zephyr SDK published this host through 0.17.4 and "
1938
+ f"dropped it in 1.0.0; the pinned SDK serves {served} only. macos-aarch64 is "
1939
+ "not a substitute -- Rosetta translates x86_64 for Apple silicon, not the "
1940
+ "reverse -- and macOS has no WSL2 equivalent to fall back to."
1941
+ )
1942
+ fix = (
1943
+ "Build on a Linux host: a linux-x86_64 VM or container on this Mac, or a "
1944
+ "remote Linux builder. Pinning an older Zephyr SDK is not an option -- the "
1945
+ f"pinned Zephyr requires {ZEPHYR_SDK_INSTALL_VERSION}, which is past the "
1946
+ "release that dropped macos-x86_64."
1947
+ )
1948
+ else:
1949
+ detail = f"The Zephyr SDK publishes no host build for {tag}. Served hosts are {served}."
1950
+ fix = f"Build on one of {served} -- natively, or in a VM/container on this machine."
1951
+ return Check("zephyrSdkAvailableForHost", "fail", detail, fix, scope="host")
1952
+
1953
+
1954
+ def _enable_long_paths_fix(key: str) -> str:
1955
+ """The elevated one-liner that sets `LongPathsEnabled` -- shared by every
1956
+ `long_paths_check` arm that names it, so the command cannot drift between
1957
+ them."""
1958
+ return (
1959
+ "Enable long paths in an ELEVATED PowerShell, then reopen your shell and VS "
1960
+ f"Code so new processes pick it up: New-ItemProperty -Path '{key}' -Name "
1961
+ "LongPathsEnabled -Value 1 -PropertyType DWORD -Force"
1962
+ )
1963
+
1964
+
1965
+ #: Fix #3 in tan-cli#306: the remedy must name this EXACT command, verbatim
1966
+ #: and runnable, no elevation needed (unlike `_enable_long_paths_fix`, which
1967
+ #: touches `HKLM`) -- the cheaper fix, and the one that unblocks the actual
1968
+ #: reported failure (`west update`'s own `git` calls).
1969
+ _GIT_LONG_PATHS_FIX = "Enable it in git: git config --global core.longpaths true"
1970
+
1971
+
1972
+ def long_paths_check(registry_enabled: bool | None, git_core_longpaths: bool | None) -> Check:
1973
+ """`longPaths` -- Windows only. Mirrors
1974
+ `tan_core::host_env::long_paths_check`.
1975
+
1976
+ Two independent axes, and conflating them into one is exactly the defect
1977
+ tan-cli#306 reports. `LongPathsEnabled` (the registry) governs manifested
1978
+ Win32 API calls (CMake, Ninja, a plain file open); it does nothing for
1979
+ git, which refuses any path past its own limit unless ITS OWN
1980
+ `core.longpaths` is set, regardless of the registry. `west update`
1981
+ clones/checks out every Zephyr module with `git`, so on a fresh `HOME`
1982
+ (no global `.gitconfig` -- a first-run customer's exact state) the
1983
+ registry read alone reported `pass` while `west update` died on
1984
+ `hal_nxp`'s `tf-psa-crypto` vendor tree with "Filename too long".
1985
+
1986
+ **`Fail`, not `Warn`, exactly when the registry reads enabled and git's
1987
+ does not.** That combination is not a probability the way a bare
1988
+ disabled registry flag is: `west update` runs `git`, `git` is the first
1989
+ thing in the whole toolchain to touch a long path, and its own setting
1990
+ says no -- the break is certain. Anything softer here would repeat the
1991
+ exact defect this check exists to fix.
1992
+
1993
+ **`Warn`, not `Fail` or `Pass`, when git is set but the registry is
1994
+ not.** Git manages long paths on its own once `core.longpaths=true` (it
1995
+ prefixes paths with `\\\\?\\` internally and never consults the
1996
+ registry), so the specific failure this check exists to catch will not
1997
+ reproduce -- but `LongPathsEnabled` still governs every OTHER manifested
1998
+ tool in the chain, so real residual risk remains.
1999
+
2000
+ **`Warn` when neither is set** -- the original, pre-#306 severity for a
2001
+ bare disabled registry flag: workspace-root-depth-dependent, not
2002
+ certain.
2003
+ """
2004
+ key = r"HKLM\SYSTEM\CurrentControlSet\Control\FileSystem"
2005
+ registry_on = registry_enabled is True
2006
+ git_on = git_core_longpaths is True
2007
+
2008
+ if registry_enabled is True:
2009
+ registry_detail = f"{key}\\LongPathsEnabled = 1"
2010
+ elif registry_enabled is False:
2011
+ registry_detail = f"{key}\\LongPathsEnabled is 0 or unset"
2012
+ else:
2013
+ registry_detail = f"{key}\\LongPathsEnabled could not be read"
2014
+
2015
+ if git_core_longpaths is True:
2016
+ git_detail = "git core.longpaths is true"
2017
+ elif git_core_longpaths is False:
2018
+ git_detail = "git core.longpaths is unset or false"
2019
+ else:
2020
+ git_detail = "git core.longpaths could not be determined"
2021
+
2022
+ if registry_on and git_on:
2023
+ status = "pass"
2024
+ headline = "Windows long paths are enabled at both the OS level and in git."
2025
+ fix = None
2026
+ elif registry_on and not git_on:
2027
+ status = "fail"
2028
+ headline = (
2029
+ "Windows reports long paths enabled, but git does not honour that flag: git "
2030
+ "has its own core.longpaths and refuses paths past its limit without it, "
2031
+ "regardless of the registry. west update runs git, so bootstrap WILL fail on "
2032
+ "a long Zephyr module path (e.g. hal_nxp's tf-psa-crypto vendor tree) even "
2033
+ "though this host looks fine."
2034
+ )
2035
+ fix = _GIT_LONG_PATHS_FIX
2036
+ elif git_on:
2037
+ status = "warn"
2038
+ headline = (
2039
+ "git's own core.longpaths is set, so west update's git operations are safe. "
2040
+ "Windows' LongPathsEnabled is not, though, and every OTHER tool in the build "
2041
+ "chain (CMake, Ninja, plain Win32 file APIs) relies on it -- a sufficiently "
2042
+ "deep workspace can still cross MAX_PATH outside of git."
2043
+ )
2044
+ fix = _enable_long_paths_fix(key)
2045
+ else:
2046
+ status = "warn"
2047
+ headline = (
2048
+ "Neither Windows' LongPathsEnabled nor git's core.longpaths is set. A Zephyr "
2049
+ "build/ tree nests deep enough to cross the 260-character MAX_PATH limit, and "
2050
+ 'it surfaces as a git "Filename too long" error during west update, or a '
2051
+ "CMake/compiler error about a file that exists."
2052
+ )
2053
+ fix = f"{_GIT_LONG_PATHS_FIX}\n{_enable_long_paths_fix(key)}"
2054
+
2055
+ return Check("longPaths", status, f"{headline} ({registry_detail}; {git_detail}).", fix, scope="host")
2056
+
2057
+
2058
+ def tan_install_check(verdict: StaleVerdict | None, version: str) -> Check:
2059
+ """`tanInstall` -- is the RUNNING `tan` behind the source it was installed
2060
+ from? `warn` when so (never `fail`: a stale tan still works, and `exit_code_for`
2061
+ reserves exit 4 for real breakage); `pass` when current OR when it cannot be
2062
+ told (no provenance record, offline, no git) -- an unanswerable question is
2063
+ not a problem. The verdict logic lives in `tan.core.doctor_stale`."""
2064
+ return Check(
2065
+ "tanInstall",
2066
+ "pass" if verdict is None else "warn",
2067
+ f"tan {version}" if verdict is None else verdict.detail,
2068
+ None if verdict is None else verdict.fix,
2069
+ scope="host",
2070
+ )
2071
+
2072
+
2073
+ def _tan_install_check() -> Check:
2074
+ """IO half of `tan_install_check`; `TAN_DOCTOR_OFFLINE=1` skips its one
2075
+ short-timeout `git ls-remote`."""
2076
+ from tan.version import TAN_VERSION
2077
+
2078
+ verdict = running_tan_verdict(
2079
+ TAN_VERSION, _resolve_git_executable(), bool(os.environ.get("TAN_DOCTOR_OFFLINE"))
2080
+ )
2081
+ return tan_install_check(verdict, TAN_VERSION)
2082
+
2083
+
2084
+ def home_path_check(home: str | None) -> Check:
2085
+ """`homePath` -- does the home directory contain a space? Mirrors
2086
+ `tan_core::host_env::home_path_check`.
2087
+
2088
+ `Warn`, not `Fail`: a space in `C:\\Users\\Jane Doe` is a real historical
2089
+ Zephyr breakage (unquoted paths through CMake/west/Kconfig), but most of
2090
+ the chain quotes correctly now and plenty of hosts with a space build
2091
+ fine -- degraded-but-usable, not a host the toolchain cannot run on at
2092
+ all. `Fail` here would exit 4 for every user whose Windows account name
2093
+ is two words.
2094
+
2095
+ All platforms, not Windows-only: a POSIX `/home/jane doe` breaks the same
2096
+ way -- Windows is merely where `%USERPROFILE%` is derived from a display
2097
+ name the user never chose.
2098
+ """
2099
+ if home is None:
2100
+ return Check(
2101
+ "homePath",
2102
+ "warn",
2103
+ "Could not resolve the home directory (neither USERPROFILE nor HOME is set).",
2104
+ "Set HOME (or USERPROFILE on Windows) -- tan resolves ~/.alp for the SDK cache "
2105
+ "and the global default-SDK pointer from it.",
2106
+ scope="host",
2107
+ )
2108
+ if " " in home:
2109
+ return Check(
2110
+ "homePath",
2111
+ "warn",
2112
+ f"Home directory contains a space: {home}. Zephyr's CMake/west/Kconfig chain "
2113
+ "has historically broken on unquoted paths, and a workspace created under it "
2114
+ "inherits the space.",
2115
+ "Create the workspace at a space-free path (e.g. C:\\alp or /opt/alp) and run "
2116
+ "tan from there with --project, rather than under the home directory.",
2117
+ scope="host",
2118
+ )
2119
+ return Check("homePath", "pass", f"Home directory has no spaces: {home}", scope="host")
2120
+
2121
+
2122
+ # ---------------------------------------------------------------------------
2123
+ # Build-environment preflight (tan-cli#294 finding 2, reintroducing
2124
+ # tan-cli#100, #98, #159): does a build even have a shot at starting?
2125
+ #
2126
+ # Folded into PLAIN `tan doctor`, mirroring
2127
+ # `tan_core::preflight::build_preflight_checks` -- #100's own words for the
2128
+ # gap this closes: "probed nothing about the build environment and printed
2129
+ # byte-identical output across four materially different host states."
2130
+ #
2131
+ # `westResolved` (the venv-resolved `west` binary's own presence, tan-cli#123
2132
+ # reintroduced) and `zephyrWorkspace`'s severity/gating are now IN scope here
2133
+ # too (tan-cli#290) -- see `west_resolved_check`/`zephyr_workspace_check`'s
2134
+ # own docstrings. `workspace`/`zephyrVersion`/`zephyrWorkspace` below are all
2135
+ # sourced from the SHARED `tan.core.venv.west_workspace_dir` (tan-cli#294
2136
+ # review) -- ALL THREE of its steps, including the `$ZEPHYR_BASE`-derived,
2137
+ # manifest-verified fallback. A fourth, partial copy of the same search
2138
+ # (this module's own retired `_resolve_west_workspace_dir`) previously
2139
+ # covered only the project-tree walk and the SDK-derived layout, so a host
2140
+ # relying SOLELY on a manually exported `$ZEPHYR_BASE` outside both a
2141
+ # project tree and `<sdk-parent>` reported a false `workspace` Fail -- "no
2142
+ # Zephyr workspace -- run `tan bootstrap`" -- that would have the customer
2143
+ # bootstrap a SECOND workspace. Importing the one shared resolver closed
2144
+ # that gap and retired the fourth copy one commit before this one; see
2145
+ # `tan.core.venv.west_workspace_dir`'s own docstring for why the search
2146
+ # lives there and not here.
2147
+ # ---------------------------------------------------------------------------
2148
+
2149
+
2150
+ def _broken_global_default() -> str | None:
2151
+ """The raw `sdkPath` `~/.alp/sdk-default` names, ONLY when that pointer
2152
+ file exists but its target is NOT a valid alp-sdk checkout (tan-cli#344).
2153
+ `None` when the pointer is absent, unreadable/malformed, or DOES resolve
2154
+ -- every one of those is indistinguishable from "nothing configured" and
2155
+ stays that way; this exists to name the one case that is not.
2156
+
2157
+ Reads the exact file `sdk_cmd.resolve_sdk_tiered` already reads
2158
+ (`_pointer_target(_home_alp_dir() / "sdk-default")` + `_has_loader_script`)
2159
+ the SAME way, purely for this one extra fact -- it changes no resolution
2160
+ outcome (`resolve_sdk_root_ladder`/`resolve_sdk_tiered` are untouched by
2161
+ this function; it is called separately, only to feed `sdk_check`'s
2162
+ report). `resolve_sdk_tiered` itself already tracks an analogous broken
2163
+ POINTER for the project-pin tier (`ActiveSdk.broken_project_pin`) and
2164
+ surfaces it via `project_pin_issue` regardless of which lower tier
2165
+ answers -- this is the same idea one tier up, for the one tier that had
2166
+ no such memory at all: a dangling global default fell through silently,
2167
+ with nothing left to report it had ever existed.
2168
+ """
2169
+ target = _pointer_target(_home_alp_dir() / "sdk-default")
2170
+ if target is None or _has_loader_script(Path(target)):
2171
+ return None
2172
+ return target
2173
+
2174
+
2175
+ def sdk_check(
2176
+ sdk_root: str | None,
2177
+ project_scope: str | None,
2178
+ tier: str | None = None,
2179
+ unselected_candidate: str | None = None,
2180
+ broken_global_default: str | None = None,
2181
+ divergent_candidate: str | None = None,
2182
+ dangling_flag_root: str | None = None,
2183
+ ) -> Check:
2184
+ """`sdk` -- is an alp-sdk checkout resolved at all? Mirrors
2185
+ `tan_core::preflight::build_preflight_checks`'s `sdk` check.
2186
+
2187
+ `project_scope` (the `--project` value, unjoined) used to name a SCOPED
2188
+ `tan sdk switch <path>` fix (tan-cli#101: the `.alp/sdk-path` pointer
2189
+ `sdk switch` writes is scoped to `--project`, so a bare `tan sdk switch
2190
+ <path>` from a `tan --project <p> doctor` run would have reported success
2191
+ while changing nothing about THIS invocation). That fix is moot now that
2192
+ `sdk switch` refuses outright in every build of tan on this branch
2193
+ (tan-cli#305, `sdk_cmd._run_not_ported`) -- recommending it, scoped or
2194
+ not, was the actual dead end #305 reported, since the ONLY thing left
2195
+ that resolves an SDK at all is `--sdk-root`, which needs no scoping. The
2196
+ parameter stays (worded into the fail detail below) because `--project`
2197
+ is still a fact worth naming, just no longer the reason for a different
2198
+ remedy.
2199
+
2200
+ `tier`/`unselected_candidate` (tan-cli#301) -- a reported host named THREE
2201
+ different roots in one report (a leftover `globalDefault`, a stale
2202
+ `$ZEPHYR_BASE` workspace, and the checkout the user was actually standing
2203
+ in, which appeared nowhere), and `tan doctor`/`tan bootstrap` disagreed
2204
+ about which SDK a bare invocation meant. `GlobalDefault` outranking
2205
+ `Discovery` is deliberate (tan-cli#263 made pins absolute on purpose) --
2206
+ NO behaviour change here, only visibility: `tier` is the `SdkSourceTier`
2207
+ wire spelling (`sdkRootFlag`/`projectPin`/`globalDefault`/`discovery`)
2208
+ that answered, reported alongside the root the same way `tan sdk
2209
+ current`'s envelope already pairs `sdkPath` with `sourceTier`.
2210
+ `unselected_candidate` is a DIFFERENT checkout discoverable from cwd that
2211
+ a higher tier outranked (`None` when the winning tier already IS
2212
+ discovery, or nothing else resolves there) -- named explicitly, with how
2213
+ to select it, so a plausible checkout sitting right there does not read
2214
+ as unconsidered.
2215
+
2216
+ `broken_global_default` (tan-cli#344, `_broken_global_default` above) is
2217
+ the raw `sdkPath` a machine-global `~/.alp/sdk-default` pointer held when
2218
+ that file exists but its target is no longer a valid checkout -- only
2219
+ meaningful in the `sdk_root is None` branch (a global default that DID
2220
+ resolve never reaches this function with `sdk_root is None` at all).
2221
+ Before this, "I have nothing configured" and "what I configured is
2222
+ broken and tan silently fell through past it" printed the identical
2223
+ sentence: `NO_SDK_NEXT_STEPS`, which tells the user to clone a checkout
2224
+ and pass `--sdk-root`, with no hint the thing they already configured is
2225
+ dangling. Falling through stays correct (unchanged here) and exit 4
2226
+ stays correct (unchanged here) -- only which sentence explains it
2227
+ changes. `bootstrap_cmd`'s own broken-pointer messages
2228
+ (`global_default_pointer_fix_hint`) are the shape this matches: name the
2229
+ pointer file directly, never `tan sdk switch`, which refuses outright in
2230
+ this build (tan-cli#305) -- recommending it here would be the exact
2231
+ dead end #305 already fixed for the project-pin case.
2232
+
2233
+ `dangling_flag_root` (tan-cli#727) is the raw `--sdk-root` text when that
2234
+ flag named a path carrying no `scripts/alp_project.py`. `--sdk-root` is
2235
+ the terminal tier, so `resolve_sdk_root_ladder` hands it back unvalidated
2236
+ -- correct for a caller that only REPORTS the tier, and wrong for this
2237
+ one, whose entire job is answering "is my setup right". `tan build` given
2238
+ the same path refuses (`build_cmd`, `run_cmd`, `validate_cmd`,
2239
+ `clean_cmd`, `flash_cmd` each guard at their own call site with
2240
+ `is_sdk_root`); doctor did not, so the two commands disagreed about the
2241
+ same flag and it was doctor saying yes. Reported as its own sentence
2242
+ rather than by nulling the root into the `NO_SDK_NEXT_STEPS` branch,
2243
+ which tells the user to clone a checkout and pass `--sdk-root` -- the
2244
+ thing they just did.
2245
+ """
2246
+ if dangling_flag_root is not None:
2247
+ return Check(
2248
+ "sdk",
2249
+ "fail",
2250
+ rejected_sdk_root_message(
2251
+ dangling_flag_root,
2252
+ "`tan build` refuses this same path, so nothing this report "
2253
+ "says about the SDK describes a checkout that is there.",
2254
+ ),
2255
+ "Point --sdk-root at a directory holding the marker above. "
2256
+ "`tan bootstrap` MOVES the checkout into the workspace and "
2257
+ "rewrites the default (tan-cli#185), so a --sdk-root copied from "
2258
+ "before a bootstrap names the path it emptied.",
2259
+ scope="project",
2260
+ )
2261
+ if sdk_root is not None:
2262
+ detail = f"alp-sdk at {sdk_root}"
2263
+ if tier is not None:
2264
+ detail += f" ({tier}"
2265
+ if unselected_candidate is not None:
2266
+ detail += (
2267
+ f"; a checkout at {unselected_candidate} was not selected -- "
2268
+ f"pass --sdk-root {unselected_candidate} to use it"
2269
+ )
2270
+ detail += ")"
2271
+ if divergent_candidate is not None:
2272
+ # BOTH roots re-rendered POSIX here, unlike the `pass` branch's
2273
+ # `alp-sdk at {sdk_root}` above, which keeps this host's native
2274
+ # separators and is left byte-identical. The reason is the CODE:
2275
+ # this branch's detail becomes an `issues[].message` under
2276
+ # `sdk.discovery-divergent`, which four other commands also emit
2277
+ # -- and all four build it from `_abs_posix`. A consumer
2278
+ # correlating two envelopes would otherwise match a code across a
2279
+ # message spelled `C:/...` on one side and `C:\...` on the other.
2280
+ # The envelope is the contract; normalise the filesystem side to
2281
+ # meet it, never the other way round (caught by
2282
+ # windows-latest on tan-cli#428, green everywhere else).
2283
+ posix_root = f"alp-sdk at {_abs_posix(sdk_root)}"
2284
+ if tier is not None:
2285
+ posix_root += f" ({tier})"
2286
+ return Check(
2287
+ "sdk",
2288
+ "warn",
2289
+ f"{posix_root}; `tan init`, `tan generate` and "
2290
+ f"`tan examples` resolve a DIFFERENT checkout from this "
2291
+ f"directory ({_abs_posix(divergent_candidate)}) and report "
2292
+ f'the same sourceTier "discovery", so generated files and '
2293
+ f"the build plan can come from different SDK versions.",
2294
+ f"--sdk-root <path> to pin the one you mean for a single run, "
2295
+ f"or `tan init --sdk-root <path>` to write it into "
2296
+ f".alp/sdk-path, which outranks both discovery tiers.",
2297
+ code=SDK_DISCOVERY_DIVERGENT,
2298
+ scope="project",
2299
+ )
2300
+ return Check("sdk", "pass", detail, scope="project")
2301
+ scope_note = f" for --project {project_scope}" if project_scope is not None else ""
2302
+ if broken_global_default is not None:
2303
+ home = _home_alp_dir()
2304
+ pointer = str(home / "sdk-default")
2305
+ return Check(
2306
+ "sdk",
2307
+ "fail",
2308
+ f"no SDK selected{scope_note} -- the machine-global default "
2309
+ f'({pointer}) names "{broken_global_default}", which is not a '
2310
+ f"valid alp-sdk checkout, so tan fell through past it and found "
2311
+ f"nothing else either.",
2312
+ f"{global_default_pointer_fix_hint(pointer, str(registry_path(home)))}, or "
2313
+ f"pass --sdk-root <path> directly.",
2314
+ scope="project",
2315
+ )
2316
+ return Check(
2317
+ "sdk",
2318
+ "fail",
2319
+ f"no SDK selected{scope_note} -- {NO_SDK_NEXT_STEPS}",
2320
+ "--sdk-root <path>",
2321
+ scope="project",
2322
+ )
2323
+
2324
+
2325
+ def board_yaml_preflight_check(present: bool, project_selected: bool) -> Check:
2326
+ """`boardYaml` -- mirrors `build_preflight_checks`'s check of the same
2327
+ name, PLUS the project-selection awareness the Rust oracle's debug
2328
+ report has and this port's copy used to lack (tan-cli#294 review,
2329
+ reintroducing #100(b)): `tan bootstrap` prints `tan doctor` as the very
2330
+ next command, run from the SDK checkout root it just set up -- which has
2331
+ no `board.yaml` and needs none. Failing there made the first command a
2332
+ new customer types report `1 failed` and exit 4 for a non-problem.
2333
+
2334
+ `project_selected` is True only when `--project` or `--board-yaml` was
2335
+ actually given (mirrors `crates/tan-cli/src/commands/doctor.rs::
2336
+ project_selected` -- with neither flag the resolved path is a guess at
2337
+ the cwd, not a request) and is only read when `present` is False.
2338
+
2339
+ NOT a duplicate of a debug-report `boardYaml` check (this port has not
2340
+ built the debug half -- see the module docstring), so this is the only
2341
+ `boardYaml` check in this file and it is never dropped.
2342
+ """
2343
+ if present:
2344
+ return Check("boardYaml", "pass", "board.yaml found", scope="project")
2345
+ if project_selected:
2346
+ return Check(
2347
+ "boardYaml",
2348
+ "fail",
2349
+ "board.yaml not found -- run `tan init` or pass `--board-yaml <path>`",
2350
+ "tan init",
2351
+ scope="project",
2352
+ )
2353
+ return Check(
2354
+ "boardYaml",
2355
+ "warn",
2356
+ "no project selected -- no board.yaml found",
2357
+ "Select a project with `--project <dir>` (or `--board-yaml <path>`) to check one.",
2358
+ scope="project",
2359
+ )
2360
+
2361
+
2362
+ def libraries_check(report: LibraryReport | None) -> Check | None:
2363
+ """`libraries` -- the ADR-0018 curated-library verdict for the selected
2364
+ project: tier, licence, and whether each selection can actually be wired
2365
+ on this target.
2366
+
2367
+ Ported from alp-sdk's `scripts/alp_cli/doctor.py::_check_libraries`
2368
+ (ADR-0020 end-state B -- a user-facing check belongs on the user command
2369
+ surface, which is `tan`; alp-sdk keeps the library LAYER, not a second CLI
2370
+ reporting on it). `tan.core.doctor_libraries` owns the resolution, the
2371
+ outcome vocabulary and the defect the port fixed; this is the mapping.
2372
+
2373
+ `None` -- no row at all -- for no project in scope and for one selecting
2374
+ no libraries; a permanent "0 selected" line would sit in front of every
2375
+ customer not using the feature, and `venv_provenance_check` is precedent
2376
+ for declining to speak over speaking emptily.
2377
+
2378
+ Never `fail`: `tan build` refuses an unwireable selection outright, so the
2379
+ hard gate exists already and is the emit, and a `fail` here would also
2380
+ cost exit 4 -- making "you picked a library this board cannot run" read
2381
+ identically to "you have no toolchain". alp-sdk's original call, kept.
2382
+ `scope="project"` because the subject is that board.yaml against that
2383
+ checkout, which answers identically on every host (`doctor_scope`)."""
2384
+ if report is None:
2385
+ return None
2386
+ joined = ", ".join(report.labels)
2387
+ count = len(report.names)
2388
+ if report.outcome == "ok":
2389
+ return Check(
2390
+ "libraries", "pass", f"{count} selected, all compatible: {joined}", scope="project"
2391
+ )
2392
+ if report.outcome == "unreadable":
2393
+ return Check("libraries", "warn", report.detail, scope="project")
2394
+ if report.outcome == "unverifiable":
2395
+ return Check(
2396
+ "libraries",
2397
+ "warn",
2398
+ f"{count} selected, none verified ({joined}) -- {report.detail}",
2399
+ # `NO_SDK_NEXT_STEPS`, never a hand-written `tan sdk switch` --
2400
+ # that verb is refused (test_sdk_onboarding_dead_end.py).
2401
+ f"{NO_SDK_NEXT_STEPS}, then re-run `tan doctor`.",
2402
+ scope="project",
2403
+ )
2404
+ return Check(
2405
+ "libraries",
2406
+ "warn",
2407
+ f"{count} selected: {joined} -- INCOMPATIBLE: {report.detail}",
2408
+ "Fix or remove the offending `libraries:` entry in board.yaml; `tan build` "
2409
+ "rejects it as a hard error.",
2410
+ scope="project",
2411
+ )
2412
+
2413
+
2414
+ def workspace_patches_check(result: PatchCheck, workspace_dir: str) -> Check:
2415
+ """`workspacePatches` (tan-cli#1376) -- is alp-sdk's `zephyr/patches.yml`
2416
+ applied in the resolved workspace? A tree without them still BUILDS; the
2417
+ gap shows up on the device (`alp_camera_open` -> `ALP_ERR_NOSUPPORT` for a
2418
+ missing Alif clock `set_rate`). `unknown` (never a failure) when the SDK has
2419
+ no verifier or the check could not run, so an offline doctor stays green."""
2420
+ if result.state == WORKSPACE_PATCHES_APPLIED:
2421
+ return Check(
2422
+ "workspacePatches", "pass",
2423
+ f"zephyr/patches.yml {result.note} in {workspace_dir}", scope="project",
2424
+ )
2425
+ if result.state == WORKSPACE_PATCHES_MISSING:
2426
+ return Check(
2427
+ "workspacePatches",
2428
+ "warn",
2429
+ f"alp-sdk's zephyr/patches.yml is not applied in {workspace_dir}: "
2430
+ f"{describe_unapplied(result.patches, result.modules)}. The build still succeeds, "
2431
+ "but features that need them fail at runtime (for example `alp_camera_open` "
2432
+ f"returns ALP_ERR_NOSUPPORT). Fix: {patch_fix_text(result.modules, workspace_dir)}.",
2433
+ "tan bootstrap",
2434
+ scope="project",
2435
+ )
2436
+ return Check(
2437
+ "workspacePatches", "unknown", f"patches not checked: {result.note}.", scope="project"
2438
+ )
2439
+
2440
+
2441
+ def zephyr_base_check(env_value: str | None, workspace_dir: str) -> Check | None:
2442
+ """`zephyrBase` (tan-cli#1376) -- only when `$ZEPHYR_BASE` names a tree other
2443
+ than the resolved workspace's zephyr, which tan ignores. A `pass` carrying
2444
+ the explanation: it is information, not a problem with the host."""
2445
+ note = zephyr_base_note(env_value, str(Path(workspace_dir) / "zephyr"))
2446
+ if note is None:
2447
+ return None
2448
+ return Check("zephyrBase", "pass", note, scope="project")
2449
+
2450
+
2451
+ def workspace_preflight_check(
2452
+ workspace_dir: str | None,
2453
+ *,
2454
+ start: str | None = None,
2455
+ sdk_root: str | None = None,
2456
+ zephyr_base: str | None = None,
2457
+ ) -> Check:
2458
+ """`workspace` -- is a Zephyr WORKSPACE (a directory holding `.west/`)
2459
+ resolved at all? Mirrors `build_preflight_checks`'s check of the same
2460
+ name. Distinct from `hostPrerequisites`/`west` above, which only confirm
2461
+ the TOOLS needed to build are on PATH -- neither confirms a Zephyr tree
2462
+ exists to build against.
2463
+
2464
+ tan-cli#1432: with `start` and `sdk_root` known, an unresolved workspace
2465
+ is judged by what `tan build` will then do (see `tan.core.
2466
+ west_workspace_refusal.unresolved_workspace_verdict`), not reported as a
2467
+ bare "no Zephyr workspace" when west's own walk still finds one.
2468
+ """
2469
+ if workspace_dir is not None:
2470
+ return Check("workspace", "pass", f"Zephyr workspace at {workspace_dir}", scope="project")
2471
+ status, detail = "fail", (
2472
+ "no Zephyr workspace -- run `tan bootstrap` (reuses a compatible Zephyr, else "
2473
+ "bootstraps one)"
2474
+ )
2475
+ if start is not None and sdk_root is not None:
2476
+ status, detail = unresolved_workspace_verdict(Path(start), zephyr_base, Path(sdk_root))
2477
+ return Check("workspace", status, detail, "tan bootstrap", scope="project")
2478
+
2479
+
2480
+ def zephyr_version_preflight_check(
2481
+ workspace_version: str | None, sdk_pin: str | None
2482
+ ) -> Check | None:
2483
+ """`zephyrVersion` -- does a REUSED workspace's Zephyr match the active
2484
+ SDK's `west.yml` pin? Mirrors `build_preflight_checks`'s check
2485
+ (tan-cli#98/#159): compared at full `MAJOR.MINOR.PATCH`, because a
2486
+ truncated `MAJOR.MINOR` comparison let a patch-level pin bump
2487
+ (`v4.4.0` -> `v4.4.1`) read as a match -- the drifted-checkout shape of
2488
+ the alp-sdk#855 incident.
2489
+
2490
+ `None` (no check emitted) when either side is unknown, matching Rust's
2491
+ own skip: don't nag when this cannot actually be verified.
2492
+
2493
+ **`Fail`, not `Warn`** (#159): a reused workspace on the wrong Zephyr
2494
+ does not "maybe" break the build -- it compiles against a different
2495
+ Zephyr than the plan was emitted for, and a Warn here is indistinguishable
2496
+ from a check that can never fail.
2497
+ """
2498
+ if workspace_version is None or sdk_pin is None:
2499
+ return None
2500
+ if workspace_version == sdk_pin:
2501
+ return Check(
2502
+ "zephyrVersion", "pass", f"Zephyr v{workspace_version} matches the SDK pin",
2503
+ scope="project",
2504
+ )
2505
+ return Check(
2506
+ "zephyrVersion",
2507
+ "fail",
2508
+ f"reused Zephyr v{workspace_version} != SDK pin v{sdk_pin} -- run `tan bootstrap` "
2509
+ "to refresh the workspace",
2510
+ "tan bootstrap",
2511
+ scope="project",
2512
+ )
2513
+
2514
+
2515
+ # ---------------------------------------------------------------------------
2516
+ # Venv provenance (tan-cli#292 consequences 1 and 3).
2517
+ # ---------------------------------------------------------------------------
2518
+
2519
+
2520
+ def venv_provenance_check(record: WorkspaceSdkRecord | None, sdk_root: str | None) -> Check | None:
2521
+ """`venvProvenance` -- does the RESOLVED workspace venv's tan-written
2522
+ record (`<topdir>/.west/tan-workspace-sdk`, tan-cli#292) name the SAME SDK
2523
+ this report resolved against? Catches two of #292's three consequences:
2524
+ `tan sdk switch` leaving the venv behind (consequence 3 -- the record
2525
+ still names the SDK that last populated it), and a neighbouring project's
2526
+ venv winning `find_workspace_venv`'s upward walk when that venv is ITSELF
2527
+ tan-bootstrapped, just for a different SDK (consequence 1 -- its own
2528
+ record then names a project this report was never asked about). Both
2529
+ otherwise surface only later, as a Zephyr build failing on a
2530
+ wrong-version package that names the SYMPTOM, not the cause.
2531
+
2532
+ **A WARNING, not a re-resolution (tan-cli#292 rc3 scope).** The record is
2533
+ not yet the resolver's primary source -- `tan build` still uses whatever
2534
+ `find_workspace_venv`'s search resolved; this only tells the customer
2535
+ that venv's packages may not match BEFORE a build fails on it. Consequence
2536
+ 1's upward-walk case is caught only when the neighbouring venv carries its
2537
+ OWN record; one populated by a bare `west update` with no tan involvement
2538
+ anywhere still resolves silently -- the same gap `west_workspace_dir`'s
2539
+ `$ZEPHYR_BASE` manifest guard cannot close for an unrelated tree with no
2540
+ alp-sdk manifest to check against either. The full record-primary
2541
+ resolver the issue also proposes is out of scope for this fix; see the
2542
+ issue for the follow-up.
2543
+
2544
+ `None` (no check emitted, matching `zephyr_version_preflight_check`'s own
2545
+ skip) when there is nothing to compare: no venv resolved, it carries no
2546
+ record at all -- a workspace bootstrapped by alp-sdk's own `bootstrap.sh`
2547
+ writes none (`crates/tan-cli/src/venv.rs:25-27`), and neither does a tan
2548
+ predating tan-cli#292 -- or no `sdk_root` resolved to compare against.
2549
+ """
2550
+ if record is None or sdk_root is None:
2551
+ return None
2552
+ if os.path.normcase(_abs_posix(record.sdk_path)) == os.path.normcase(_abs_posix(sdk_root)):
2553
+ return Check(
2554
+ "venvProvenance", "pass", f"workspace venv populated for the active SDK ({record.sdk_path})",
2555
+ scope="project",
2556
+ )
2557
+ return Check(
2558
+ "venvProvenance",
2559
+ "warn",
2560
+ f"workspace venv was populated for a different SDK ({record.sdk_path}) than the "
2561
+ f"one currently selected ({sdk_root}) -- Zephyr packages installed into it may not "
2562
+ "match; run `tan bootstrap` to resync the venv",
2563
+ "tan bootstrap",
2564
+ scope="project",
2565
+ )
2566
+
2567
+
2568
+ # ---------------------------------------------------------------------------
2569
+ # SDK provenance (tan-cli#294 finding 5; no numbered GH issue -- the Rust
2570
+ # doc comment cites "conformance Issue 4 + 6").
2571
+ # ---------------------------------------------------------------------------
2572
+
2573
+
2574
+ def sdk_provenance_check(sdk_root: str) -> Check:
2575
+ """`sdkProvenance` -- records the SDK checkout's git short-commit and
2576
+ `metadata/sdk_version.yaml` version, so a build plan can be traced back
2577
+ to the planner that produced it, and warns when the checkout is behind
2578
+ its upstream tracking ref. Mirrors
2579
+ `crates/tan-cli/src/commands/doctor.rs`'s `append_sdk_provenance`.
2580
+
2581
+ Advisory only: `git_behind_upstream` reads the local remote-tracking ref
2582
+ and performs no network fetch, so it only reflects the checkout's state
2583
+ as of the last `git fetch` -- never blocks a build over it.
2584
+ """
2585
+ # tan-cli#797: resolved ONCE and threaded through both git calls below --
2586
+ # each spawns `git` itself, and re-resolving per call would not be wrong,
2587
+ # only wasteful; the point that matters is that neither one hands
2588
+ # `subprocess` the bare `"git"` (see `_resolve_git_executable`).
2589
+ git_exe = _resolve_git_executable()
2590
+ commit = _git_short_commit(sdk_root, git_exe)
2591
+ version = _read_sdk_version(sdk_root)
2592
+ if version and commit:
2593
+ detail = f"alp-sdk {version} @ {commit}"
2594
+ elif commit:
2595
+ detail = f"alp-sdk @ {commit}"
2596
+ elif version:
2597
+ detail = f"alp-sdk {version}"
2598
+ else:
2599
+ detail = f"alp-sdk at {sdk_root} (no git checkout / metadata/sdk_version.yaml)"
2600
+
2601
+ behind = _git_behind_upstream(sdk_root, git_exe)
2602
+ if behind is not None and behind > 0:
2603
+ return Check(
2604
+ "sdkProvenance",
2605
+ "warn",
2606
+ f"{detail} -- {behind} commit(s) behind upstream",
2607
+ f"Update the SDK checkout: git -C {sdk_root} pull",
2608
+ scope="project",
2609
+ )
2610
+ return Check("sdkProvenance", "pass", detail, scope="project")
2611
+
2612
+
2613
+ # _resolve_git_executable / _is_own_git_checkout / _git_short_commit /
2614
+ # _git_behind_upstream moved to tan.core.doctor_git (tan-cli#797 module-size
2615
+ # budget); imported above.
2616
+
2617
+ def _read_sdk_version(root: str) -> str | None:
2618
+ """Read a version from `<root>/metadata/sdk_version.yaml`. Shares
2619
+ `sdk_cmd.parse_sdk_version_yaml` with `check_sdk_readiness`
2620
+ (tan-cli#162), so `tan sdk install`/`current`/`switch` and this check
2621
+ read the SAME version out of the SAME file rather than two copies of the
2622
+ scan able to disagree."""
2623
+ text = _read_text(Path(root) / "metadata" / "sdk_version.yaml")
2624
+ if text is None:
2625
+ return None
2626
+ return parse_sdk_version_yaml(text)
2627
+
2628
+
2629
+ # ---------------------------------------------------------------------------
2630
+ # Aggregation
2631
+ # ---------------------------------------------------------------------------
2632
+
2633
+
2634
+ def summarise(checks: list[Check]) -> dict[str, int]:
2635
+ """`pass`/`warn`/`fail` counts. `unknown` lands in NONE of them, so
2636
+ `sum(summary.values())` can be smaller than `len(checks)` -- deliberate, and
2637
+ the same shape the Rust `DoctorSummary` has."""
2638
+ return {
2639
+ "pass": sum(1 for c in checks if c.status == "pass"),
2640
+ "warn": sum(1 for c in checks if c.status == "warn"),
2641
+ "fail": sum(1 for c in checks if c.status == "fail"),
2642
+ }
2643
+
2644
+
2645
+ def next_steps(checks: list[Check]) -> list[str]:
2646
+ """Deduplicated fixes for non-passing checks. `unknown` contributes none:
2647
+ a check nobody could run has nothing to remediate."""
2648
+ steps: list[str] = []
2649
+ for check in checks:
2650
+ if check.status in ("pass", "unknown") or check.fix is None:
2651
+ continue
2652
+ if check.fix not in steps:
2653
+ steps.append(check.fix)
2654
+ return steps
2655
+
2656
+
2657
+ _CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
2658
+
2659
+
2660
+ def kebab_check_name(name: str) -> str:
2661
+ """`Check.name` (camelCase, also a JSON data key/display string -- left
2662
+ alone) -> the kebab-case slug an issue *code* uses, e.g. `'sevenZip'` ->
2663
+ `'seven-zip'`. `contract/issue-codes.json`'s registry gate
2664
+ (`^[a-z][a-z0-9-]*\\.[a-z0-9-]+$`) rejects camelCase; 27 issue codes
2665
+ derived straight from `check.name` shipped camelCase in v0.5.0 before this
2666
+ existed."""
2667
+ return _CAMEL_BOUNDARY.sub("-", name).lower()
2668
+
2669
+
2670
+ def checks_to_issues(checks: list[Check]) -> list[Issue]:
2671
+ """Warn/fail checks become issues; `unknown` raises none (it is not a
2672
+ problem, the question was simply not askable). The code is the check's own
2673
+ when it has one -- the frozen `bootstrap.*` spellings -- else Rust's
2674
+ `doctor.<checkName>` convention, kebab-cased (`kebab_check_name`)."""
2675
+ return [
2676
+ Issue(
2677
+ check.code or f"doctor.{kebab_check_name(check.name)}",
2678
+ "error" if check.status == "fail" else "warning",
2679
+ check.detail,
2680
+ )
2681
+ for check in checks
2682
+ if check.status in ("warn", "fail")
2683
+ ]
2684
+
2685
+
2686
+ def exit_code_for(checks: list[Check]) -> ExitCode:
2687
+ """Exit 4 on any failure. Never 0 on an unhealthy host: a green doctor over a
2688
+ broken environment converts a fixable setup problem into a mystery inside
2689
+ somebody else's build system."""
2690
+ return (
2691
+ ExitCode.DOCTOR_FAILURE
2692
+ if any(c.status == "fail" for c in checks)
2693
+ else ExitCode.SUCCESS
2694
+ )
2695
+
2696
+
2697
+ # ---------------------------------------------------------------------------
2698
+ # The IO layer: probe the host, then hand facts to the pure checks above
2699
+ # ---------------------------------------------------------------------------
2700
+
2701
+
2702
+ #: `platform.machine()` -> the Zephyr-SDK-release arch token
2703
+ #: (`tan_core::host_env::ZEPHYR_SDK_HOSTS`'s spelling). Values seen in
2704
+ #: practice: Windows `AMD64`/`ARM64`, macOS `x86_64`/`arm64`, Linux
2705
+ #: `x86_64`/`aarch64`. An unrecognised value is passed through unchanged, so
2706
+ #: `zephyr_sdk_host_check` reports it as a real, unserved tag rather than
2707
+ #: silently mapping it onto a served one.
2708
+ _ARCH_TAGS = {
2709
+ "amd64": "x86_64",
2710
+ "x86_64": "x86_64",
2711
+ "arm64": "aarch64",
2712
+ "aarch64": "aarch64",
2713
+ }
2714
+
2715
+
2716
+ def _macos_rosetta_translated() -> bool:
2717
+ """`True` when THIS process's Python interpreter is an x86_64 binary
2718
+ running under Rosetta on Apple silicon -- `sysctl -n
2719
+ sysctl.proc_translated` == 1. Mirrors
2720
+ `tan_core::host_env::arch_for_proc_translated`'s macOS probe
2721
+ (`crates/tan-cli/src/commands/doctor.rs:601-611`) via the `sysctl` CLI
2722
+ rather than a `ctypes` binding to the same `sysctlbyname` FFI -- this
2723
+ module's probes are all subprocess-based, and the sysctl is a stable
2724
+ macOS command-line surface. `probe()` (and so this) returns `False` on a
2725
+ pre-Big-Sur host where the sysctl does not exist -- the compiled arch is
2726
+ already correct there, matching Rust's `rc == 0 && translated == 1`.
2727
+
2728
+ `sysctl` itself is resolved through `tool_lookup.resolve_tool` before the
2729
+ spawn (tan-cli#797), the same treatment as this module's other probes --
2730
+ POSIX `execvp` never searches the current directory the way Windows'
2731
+ `CreateProcess` does, so a bare `argv[0]` here was never the strong form
2732
+ of the hazard, but leaving one spawn unresolved while the rest of the
2733
+ module resolves would be a needless inconsistency for the next reader to
2734
+ puzzle over. `False` when `sysctl` is not on PATH at all -- the same
2735
+ "nothing to report" verdict as every other way this probe can fail.
2736
+ """
2737
+ resolved = resolve_tool("sysctl", os.environ).resolved
2738
+ if resolved is None:
2739
+ return False
2740
+ return (
2741
+ probe(["sysctl", "-n", "sysctl.proc_translated"], executable=resolved) or ""
2742
+ ).strip() == "1"
2743
+
2744
+
2745
+ def _host_os_arch_tags() -> tuple[str, str]:
2746
+ """`(os, arch)` in `tan_core::host_env::ZEPHYR_SDK_HOSTS`'s tokens, read
2747
+ from `platform.system()`/`platform.machine()`, corrected for Rosetta.
2748
+
2749
+ Unlike the Rust oracle, this does NOT detect Windows-on-ARM x64 emulation
2750
+ (`IsWow64Process2`): tan's Python port runs under whatever interpreter is
2751
+ already installed rather than a separately-compiled per-arch binary, so
2752
+ `platform.machine()` reflects the INTERPRETER's real architecture in the
2753
+ overwhelming majority of cases (a user who installed an x86_64 Python on
2754
+ Windows-on-ARM, where Python.org has shipped a native ARM64 installer for
2755
+ some time, is the one host this can under-report -- tracked, not silently
2756
+ claimed complete).
2757
+
2758
+ macOS IS corrected (tan-cli#294 review): the opposite direction is common
2759
+ there and worse. Rosetta silently runs the far more widely distributed
2760
+ x86_64 Python build on Apple silicon, so `platform.machine()` alone
2761
+ reported `macos-x86_64` -- a FALSE HARD REFUSAL
2762
+ (`zephyr_sdk_host_check`'s `Fail`, exit 4, "build on a Linux host") on
2763
+ hardware the pinned SDK serves natively as `macos-aarch64`.
2764
+ """
2765
+ system = platform.system().lower()
2766
+ host_os = {"windows": "windows", "darwin": "macos", "linux": "linux"}.get(system, system)
2767
+ machine = platform.machine().lower()
2768
+ arch = _ARCH_TAGS.get(machine, machine)
2769
+ if host_os == "macos" and arch == "x86_64" and _macos_rosetta_translated():
2770
+ arch = "aarch64"
2771
+ return host_os, arch
2772
+
2773
+
2774
+ # classify_git_core_longpaths / _git_core_longpaths moved to
2775
+ # tan.core.doctor_git (tan-cli#797 module-size budget); imported above.
2776
+
2777
+
2778
+ def _long_paths_enabled() -> bool | None:
2779
+ """Windows `HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\
2780
+ LongPathsEnabled`, via the stdlib `winreg` module (Windows-only).
2781
+
2782
+ `None` off Windows (`long_paths_check` is never reached there --
2783
+ `_collect` gates the append on `os.name == "nt"`) and on any registry
2784
+ read failure OTHER than the value/subkey being absent -- an access
2785
+ denial, a value of the wrong type -- so the check can say "unknown"
2786
+ rather than guess. An absent value/subkey (`FileNotFoundError`) IS
2787
+ "disabled": that is the Windows default-off state and by far the most
2788
+ common one, matching `tan_core::host_env::classify_long_paths`.
2789
+ """
2790
+ if os.name != "nt":
2791
+ return None
2792
+ try:
2793
+ import winreg
2794
+ except ImportError: # pragma: no cover -- always present on Windows CPython
2795
+ return None
2796
+ try:
2797
+ with winreg.OpenKey(
2798
+ winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\FileSystem"
2799
+ ) as key:
2800
+ value, _ = winreg.QueryValueEx(key, "LongPathsEnabled")
2801
+ return bool(value)
2802
+ except FileNotFoundError:
2803
+ return False
2804
+ except OSError:
2805
+ return None
2806
+
2807
+
2808
+ #: Where the `arm-zephyr-eabi` cross compiler sits INSIDE a zephyr-sdk-1.0.1
2809
+ #: root -- the version `ZEPHYR_SDK_INSTALL_VERSION` above pins and the only
2810
+ #: one this file's fix hints (`zephyr_sdk_install_command`) ever name.
2811
+ #:
2812
+ #: tan-cli#286 third pass: the SECOND pass's blocker. `_zephyr_sdk_root_valid`
2813
+ #: and `test_doctor_command.py`'s own `_plant_zephyr_sdk` fixture both
2814
+ #: previously hardcoded the WRONG layout (un-prefixed `arm-zephyr-eabi/bin/`)
2815
+ #: independently, so they agreed with EACH OTHER instead of with a real SDK
2816
+ #: and 77 tests passed over a broken probe. Both now build from this one
2817
+ #: tuple so they cannot drift back to silently matching only each other.
2818
+ #:
2819
+ #: The `gnu/` prefix is decisive, not guessed: a maintainer build log on the
2820
+ #: exact host this check hard-failed on -- "Found assembler:
2821
+ #: <home>/zephyr-sdk-1.0.1/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc.exe"
2822
+ #: -- plus three in-repo measurements agreeing byte-for-byte:
2823
+ #: `crates/tan-core/src/runners.rs`'s real-AEN801-build fixture (`gdb:
2824
+ #: /zephyr-sdk-1.0.1/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb-py`),
2825
+ #: `crates/tan-core/src/debug_launch.rs`'s resolution test (same `gdbPath`),
2826
+ #: and `contract/fixtures/toolchains/toolchains.json`'s `du -sb` measurement
2827
+ #: of `gnu/arm-zephyr-eabi/` (784086497 bytes) as its own line item, separate
2828
+ #: from `hosttools/`.
2829
+ #:
2830
+ #: NOT widened to also accept the older, un-prefixed `arm-zephyr-eabi/bin/`
2831
+ #: layout (0.16.x): every fix hint in this file already promises exactly
2832
+ #: `--version 1.0.1`, so treating a stale sub-1.0 install as a Pass would
2833
+ #: validate a toolchain this file's own advice says to replace. NOT probing
2834
+ #: the SDK's own `sdk_version`/`sdk_toolchains` marker files either, tempting
2835
+ #: as a layout-proof alternative would be: no measurement of either file's
2836
+ #: real name, location or format exists anywhere in this repo, and guessing
2837
+ #: at one is the exact unverified-brief mistake that put the wrong compiler
2838
+ #: path here to begin with.
2839
+ ZEPHYR_SDK_TOOLCHAIN_DIR = ("gnu", "arm-zephyr-eabi", "bin")
2840
+
2841
+
2842
+ def _zephyr_sdk_root_valid(root: Path) -> bool:
2843
+ """`True` when `root` is an actually-installed Zephyr SDK -- not merely a
2844
+ directory that happens to be named right, or still named by a stale
2845
+ `ZEPHYR_SDK_INSTALL_DIR`. Probes the one file every downstream check
2846
+ (`west build`, `west flash`) actually needs: the `arm-zephyr-eabi` cross
2847
+ compiler, at `ZEPHYR_SDK_TOOLCHAIN_DIR`. `is_dir()` alone passes on an
2848
+ EMPTY directory -- the exact false Pass tan-cli#286 exists to fix;
2849
+ measuring the shipped thing instead of a directory-name proxy is what
2850
+ makes this port's docstring true.
2851
+ """
2852
+ exe = "arm-zephyr-eabi-gcc.exe" if os.name == "nt" else "arm-zephyr-eabi-gcc"
2853
+ try:
2854
+ return root.joinpath(*ZEPHYR_SDK_TOOLCHAIN_DIR, exe).is_file()
2855
+ except OSError:
2856
+ return False
2857
+
2858
+
2859
+ def _zephyr_sdk_scan_roots() -> list[Path]:
2860
+ """Every directory `_zephyr_sdk_detected` scans for a `zephyr-sdk-*`
2861
+ install, besides `/opt` -- `$HOME`, `%USERPROFILE%` AND `Path.home()`,
2862
+ ALL of them, never `HOME or USERPROFILE` -- PLUS the ADR 0021
2863
+ artifact-keyed store (`~/.alp/toolchains`, or `$ALP_TOOLCHAIN_ROOT`)
2864
+ `tan bootstrap`'s own toolchain phase installs into, two directory
2865
+ levels below `$HOME` and invisible to the first four roots (tan-cli#1186:
2866
+ a real compiler planted at that exact bootstrap-filled path was measured
2867
+ returning `False` here before this root was added).
2868
+ `_toolchain_store_scan_root`/`_is_toolchain_wreckage` are reused from
2869
+ `build.toolchain` rather than re-derived -- the SAME store root
2870
+ `${TOOLCHAIN_ROOT}` substitution (tan-cli#547) already scans, so this
2871
+ check and that one cannot independently drift on where tan's own
2872
+ installs live, and the SAME `.tmp-<pid>` wreckage exclusion applies (an
2873
+ interrupted acquisition's leftover also starts with `zephyr-sdk`). Routed
2874
+ through the same string-level `seen` dedup as the other four roots (an
2875
+ `ALP_TOOLCHAIN_ROOT` pointed at `$HOME` or `/opt` -- both legitimate,
2876
+ e.g. a bench-machine escape hatch -- would otherwise list that directory
2877
+ twice; harmless for this yes/no scan but not for the invariant the other
2878
+ roots already hold).
2879
+
2880
+ Under Git Bash/MSYS on Windows, `HOME` is a POSIX-translated path
2881
+ (`/c/Users/dev`) while the real Zephyr SDK sits under the native
2882
+ `%USERPROFILE%` (`C:\\Users\\dev\\zephyr-sdk-1.0.1`). `or`ing the two
2883
+ picks whichever is set first and silently drops the other -- proven on a
2884
+ real host: that host HAS the SDK and `_zephyr_sdk_detected()` still
2885
+ returned `False`, a hard doctor FAIL worse than the false PASS #286
2886
+ exists to fix. `Path.home()` resolves independently of both env vars
2887
+ (POSIX `pwd`/`$HOME`; Windows `USERPROFILE` via CPython's own
2888
+ `ntpath.expanduser`) and can disagree with both, so it is scanned too,
2889
+ not assumed redundant.
2890
+ """
2891
+ roots = [Path("/opt")]
2892
+ seen: set[str] = {str(roots[0])}
2893
+ for raw in (os.environ.get("HOME"), os.environ.get("USERPROFILE")):
2894
+ if raw and raw not in seen:
2895
+ seen.add(raw)
2896
+ roots.append(Path(raw))
2897
+ try:
2898
+ home = Path.home()
2899
+ except (OSError, RuntimeError):
2900
+ home = None
2901
+ if home is not None and str(home) not in seen:
2902
+ roots.append(home)
2903
+ seen.add(str(home))
2904
+ store_root = _toolchain_store_scan_root()
2905
+ if str(store_root) not in seen:
2906
+ roots.append(store_root)
2907
+ return roots
2908
+
2909
+
2910
+ def _zephyr_sdk_detected_roots() -> list[Path]:
2911
+ """EVERY root the scan validates, in precedence order (tan-cli#1483: the
2912
+ adoption check needs to see all of them, not just the first -- a
2913
+ mismatched `ZEPHYR_SDK_INSTALL_DIR` must not hide an installed copy of the
2914
+ pinned version). `ZEPHYR_SDK_INSTALL_DIR` first, then each `zephyr-sdk*`
2915
+ entry under `_zephyr_sdk_scan_roots()`.
2916
+
2917
+ Never raises: an unreadable or missing scan root is "nothing found
2918
+ there", not a doctor crash.
2919
+ """
2920
+ found: list[Path] = []
2921
+ env_dir = os.environ.get("ZEPHYR_SDK_INSTALL_DIR")
2922
+ if env_dir and _zephyr_sdk_root_valid(Path(env_dir)):
2923
+ found.append(Path(env_dir))
2924
+ for root in _zephyr_sdk_scan_roots():
2925
+ try:
2926
+ entries = sorted(root.iterdir())
2927
+ except OSError:
2928
+ continue
2929
+ for entry in entries:
2930
+ if (
2931
+ entry.name.startswith("zephyr-sdk")
2932
+ and not _is_toolchain_wreckage(entry.name)
2933
+ and _zephyr_sdk_root_valid(entry)
2934
+ ):
2935
+ found.append(entry)
2936
+ return found
2937
+
2938
+
2939
+ def _zephyr_sdk_detected_root() -> Path | None:
2940
+ """The first root `_zephyr_sdk_detected_roots` validates, or `None`."""
2941
+ roots = _zephyr_sdk_detected_roots()
2942
+ return roots[0] if roots else None
2943
+
2944
+
2945
+ def _zephyr_sdk_detected() -> bool:
2946
+ """`True` when a Zephyr SDK toolchain is installed anywhere this host
2947
+ would resolve one from. Descends from `crate::toolchain::
2948
+ resolve_toolchain_root`/`zephyr_sdk_detected` in the now-deleted Rust
2949
+ oracle (`crates/` retired in tan-cli#601) -- doctor only ever needed that
2950
+ pair's yes/no half. Build-plan `${TOOLCHAIN_ROOT}` substitution is its own
2951
+ resolver now, not `None` as it was when this docstring was last true:
2952
+ `build.toolchain.resolve_toolchain_root` (tan-cli#547), which
2953
+ `build_cmd.py` calls and passes on as `toolchain_root=toolchain.root`.
2954
+ That resolver is stricter on purpose -- a host with SEVERAL candidate
2955
+ installs and no `ZEPHYR_SDK_INSTALL_DIR` to choose between them must FAIL
2956
+ a substitution (the wrong one silently baked into a slice is worse than
2957
+ refusing), but should not fail doctor's plain yes/no here, which only
2958
+ needs to know that AT LEAST one usable toolchain exists somewhere:
2959
+ `ZEPHYR_SDK_INSTALL_DIR`, honored ONLY when the directory it names
2960
+ actually CONTAINS the toolchain (`_zephyr_sdk_root_valid` -- the variable
2961
+ is exported from a shell profile and routinely outlives the SDK it once
2962
+ pointed at, e.g. after `rm -rf ~/zephyr-sdk-0.16.5`, and an empty
2963
+ directory it never pointed at anything real for is the same failure mode
2964
+ -- trusting presence alone would report a false Pass here and the real
2965
+ failure would surface later as a raw CMake toolchain error); else any
2966
+ `zephyr-sdk*`-named directory, similarly validated, directly under
2967
+ `_zephyr_sdk_scan_roots()` -- which now also covers the ADR 0021
2968
+ artifact-keyed store `tan bootstrap` fills (tan-cli#1186). Several
2969
+ installs still count as detected here -- this is only doctor's yes/no,
2970
+ not the ambiguous-root pick `${TOOLCHAIN_ROOT}` substitution needs.
2971
+
2972
+ Never raises: an unreadable or missing scan root is "nothing found
2973
+ there", not a doctor crash. Delegates to `_zephyr_sdk_detected_root` so
2974
+ the two cannot independently drift on what "detected" means.
2975
+ """
2976
+ return _zephyr_sdk_detected_root() is not None
2977
+
2978
+
2979
+ def _resolve_dtc() -> devicetree_lint.DtcResolution:
2980
+ """Resolve `dtc` the way `zephyr/cmake/modules/FindDtc.cmake` would, and
2981
+ ask it its version -- the IO half of `devicetree_lint_check`.
2982
+
2983
+ Order matters and is CMake's, not this file's: `find_program` searches
2984
+ `CMAKE_PREFIX_PATH` BEFORE the ambient `PATH`, and the SDK's own
2985
+ `cmake/zephyr/host-tools.cmake` is the only thing that puts a
2986
+ `dtc`-bearing prefix on that list. A PATH-first probe would attribute the
2987
+ reported version to the wrong binary on a host that has both -- the same
2988
+ "which binary answered" defect tan-cli#123/#488 closed for `west`.
2989
+
2990
+ The SDK root comes from `_zephyr_sdk_detected_root()` -- the SAME
2991
+ `ZEPHYR_SDK_INSTALL_DIR`-then-scan precedence `zephyrSdk` and `toolchain`
2992
+ already trust, reused rather than re-rolled. One narrowing, stated rather
2993
+ than glossed: an SDK reachable ONLY through CMake's user package registry
2994
+ (`~/.cmake/packages/Zephyr-sdk/*`, which `west sdk install` writes and
2995
+ `FindZephyr-sdk.cmake`'s CONFIG-mode lookup consults) and living outside
2996
+ `$HOME` and `/opt` is not consulted here. That can only ever move a hosttools
2997
+ `dtc` from the first arm to the third -- `pass` either way -- and never
2998
+ manufacture the `warn`, which is the arm that would cost a customer
2999
+ attention.
3000
+
3001
+ Never raises: `_zephyr_sdk_detected_root`, `on_path` and `probe` are all
3002
+ non-raising by contract (see the module docstring), and the one direct
3003
+ filesystem touch here catches `OSError` itself.
3004
+ """
3005
+ host_os, host_arch = _host_os_arch_tags()
3006
+ found: str | None = None
3007
+ origin = devicetree_lint.ORIGIN_ABSENT
3008
+ sdk_dir = _zephyr_sdk_detected_root()
3009
+ if sdk_dir is not None:
3010
+ bin_dir = devicetree_lint.hosttools_bin_dir(sdk_dir, host_os, host_arch)
3011
+ if bin_dir is not None:
3012
+ candidate = bin_dir / ("dtc.exe" if os.name == "nt" else "dtc")
3013
+ try:
3014
+ present = candidate.is_file()
3015
+ except OSError:
3016
+ present = False
3017
+ if present:
3018
+ found, origin = str(candidate), devicetree_lint.ORIGIN_HOSTTOOLS
3019
+ if found is None:
3020
+ on_path_dtc = on_path("dtc")
3021
+ if on_path_dtc is not None:
3022
+ found, origin = on_path_dtc, devicetree_lint.ORIGIN_PATH
3023
+ version = (
3024
+ devicetree_lint.parse_dtc_version(probe([found, "--version"]))
3025
+ if found is not None
3026
+ else None
3027
+ )
3028
+ return devicetree_lint.DtcResolution(found, origin, version)
3029
+
3030
+
3031
+ def _probe_host_python(
3032
+ floor: tuple[int, int], candidates: list | None = None
3033
+ ) -> tuple[str, tuple[int, int], str] | None:
3034
+ """First candidate that RUNS and clears `floor`; else the first that merely
3035
+ ran, so the too-old message can name a real version instead of "did not
3036
+ run". Mirrors `crate::util::probe_host_python`.
3037
+
3038
+ tan-cli#797: `candidate[0]` (`"py"`/`"python"`/`"python3"`) is resolved
3039
+ through `tool_lookup.resolve_tool` and spawned as `executable=`, keeping
3040
+ `candidate` itself -- and therefore `entry`'s display spelling -- bare. A
3041
+ candidate that does not resolve is skipped outright, the same "no answer"
3042
+ treatment `probe()` already gives every other way this can fail; it is
3043
+ never handed to `subprocess` unresolved.
3044
+
3045
+ The returned 3-tuple carries the RESOLVED path as its third element
3046
+ alongside the display spelling and version, so a caller that needs to
3047
+ spawn this same interpreter again (`_posix_venv_capable`'s `ensurepip`
3048
+ probe) threads the already-resolved path through as `executable=` instead
3049
+ of re-deriving argv from the display spelling and handing THAT bare to
3050
+ `subprocess` -- the resolve-then-spawn-something-else divergence
3051
+ tan-cli#797 exists to close. `python_check` only reads the first two
3052
+ elements; a caller wanting just those may slice `found[:2]`.
3053
+ """
3054
+ if candidates is None:
3055
+ candidates = probe_all_host_pythons()
3056
+ found = select_host_python(candidates, floor)
3057
+ return None if found is None else (found.display, found.version, found.resolved)
3058
+
3059
+
3060
+ @dataclass(frozen=True)
3061
+ class ManifestLoad:
3062
+ """The result of resolving `<sdk>/metadata/bootstrap.json`.
3063
+
3064
+ `is_real` is the provenance verdict as DATA, set exactly once, at the one
3065
+ return that actually read and parsed a manifest -- never re-derived by a
3066
+ caller sniffing `source`'s prose. `source` is still carried for display
3067
+ (the message names WHICH file or fallback), but nothing downstream may
3068
+ infer `is_real` from it: that used to be `source.startswith("facts from
3069
+ alp-sdk")`, which silently flips the verdict the moment this docstring's
3070
+ or `source`'s wording changes, with nothing to catch it.
3071
+ """
3072
+
3073
+ facts: dict
3074
+ source: str
3075
+ error: str | None
3076
+ is_real: bool
3077
+ #: tan-cli#1066 review: what was unreadable inside a manifest that was
3078
+ #: otherwise read FINE -- today only the `artifactProvenance` block. A
3079
+ #: SECOND field rather than reusing `error`, because the two mean opposite
3080
+ #: things to the caller: `error` says "this manifest was rejected and the
3081
+ #: built-in fallback list is in play", which would be false here (the tool
3082
+ #: list, the floor and the install commands were all read normally, and
3083
+ #: `is_real` stays true). Both raise the same `bootstrapManifest` warn and
3084
+ #: the same `doctor.bootstrap-manifest` issue -- they differ only in what
3085
+ #: the line says, and saying the wrong one would send a customer hunting a
3086
+ #: prerequisite list that is not actually stale.
3087
+ provenance_error: str | None = None
3088
+
3089
+
3090
+ def _load_manifest(sdk_root: str | None) -> ManifestLoad:
3091
+ """Resolve the prerequisites facts from `<sdk>/metadata/bootstrap.json`.
3092
+
3093
+ A missing or malformed manifest is a WARNING with documented fallbacks, not
3094
+ a refusal: doctor's whole job is to run on a host where things are wrong,
3095
+ and a doctor that cannot start because the thing it diagnoses is broken is
3096
+ the failure mode it exists to prevent.
3097
+ """
3098
+ fallback = {
3099
+ "posix": ["git", "cmake", "python3", "ninja"],
3100
+ "windows": ["git", "cmake", "python", "ninja"],
3101
+ "pythonMinVersion": f"{FALLBACK_PYTHON_FLOOR[0]}.{FALLBACK_PYTHON_FLOOR[1]}",
3102
+ "install": {},
3103
+ }
3104
+ if sdk_root is None:
3105
+ return ManifestLoad(
3106
+ fallback,
3107
+ "tan's built-in fallback list (no alp-sdk checkout resolved)",
3108
+ None,
3109
+ is_real=False,
3110
+ )
3111
+ path = Path(sdk_root) / "metadata" / "bootstrap.json"
3112
+ text = _read_text(path)
3113
+ if text is None:
3114
+ return ManifestLoad(
3115
+ fallback,
3116
+ "tan's built-in fallback list",
3117
+ f"could not read {path}",
3118
+ is_real=False,
3119
+ )
3120
+ try:
3121
+ facts = json.loads(text)
3122
+ except ValueError as err:
3123
+ return ManifestLoad(
3124
+ fallback, "tan's built-in fallback list", f"{path} is not valid JSON: {err}", is_real=False
3125
+ )
3126
+ if not isinstance(facts, dict):
3127
+ return ManifestLoad(
3128
+ fallback, "tan's built-in fallback list", f"{path} is not a JSON object", is_real=False
3129
+ )
3130
+ # tan-cli#488 defect 3: `schemaVersion` read on its own FIRST, mirroring
3131
+ # `tan.core.bootstrap.parse_bootstrap_manifest` -- the ONE other reader of
3132
+ # this same file -- so the two commands agree about what a future,
3133
+ # reshaped manifest means. Before this, `_load_manifest` accepted ANY
3134
+ # `prerequisites` object regardless of `schemaVersion`, so an SDK whose
3135
+ # manifest `tan bootstrap` refuses outright (a schema this build does not
3136
+ # understand) still yielded `hostPrerequisites: pass`, zero tools probed,
3137
+ # and no `bootstrapManifest` warning -- a clean bill of health for a
3138
+ # manifest that was never actually read. `bool` excluded explicitly, same
3139
+ # reason `parse_bootstrap_manifest` excludes it: `True == 1` in Python.
3140
+ schema_version = facts.get("schemaVersion")
3141
+ if not isinstance(schema_version, int) or isinstance(schema_version, bool):
3142
+ return ManifestLoad(
3143
+ fallback,
3144
+ "tan's built-in fallback list",
3145
+ f"{path} could not be read: missing `schemaVersion`",
3146
+ is_real=False,
3147
+ )
3148
+ if schema_version != BOOTSTRAP_MANIFEST_SCHEMA_VERSION:
3149
+ return ManifestLoad(
3150
+ fallback,
3151
+ "tan's built-in fallback list",
3152
+ # tan-cli#488 defect 2: no trailing period -- this string is
3153
+ # always read back through `f"... rejected: {loaded.error}. Falling
3154
+ # back to ..."` (below), which supplies its own; a period here
3155
+ # rendered "...outright.. Falling back to" (double stop).
3156
+ f"{path} declares schemaVersion {schema_version}, but this `tan` "
3157
+ f"supports only {BOOTSTRAP_MANIFEST_SCHEMA_VERSION}. Update `tan`, or "
3158
+ f"pin an SDK whose metadata/bootstrap.json this version understands; "
3159
+ f"`tan bootstrap` already refuses this manifest outright",
3160
+ is_real=False,
3161
+ )
3162
+ prerequisites = facts.get("prerequisites")
3163
+ if not isinstance(prerequisites, dict):
3164
+ return ManifestLoad(
3165
+ fallback,
3166
+ "tan's built-in fallback list",
3167
+ f"{path} has no `prerequisites` object",
3168
+ is_real=False,
3169
+ )
3170
+ west = facts.get("west")
3171
+ if isinstance(west, dict):
3172
+ prerequisites = {**prerequisites, "_pipSpec": west.get("pipSpec")}
3173
+ # tan-cli#606: `zephyr.pythonMinVersion` is a SEPARATE, Zephyr-scoped
3174
+ # floor from `prerequisites.pythonMinVersion` above (alp-sdk#1078) -- not
3175
+ # a duplicate of it. Injected onto the returned `prerequisites` dict the
3176
+ # same way `_pipSpec` is, so `_zephyr_manifest_floor_from_facts` has one
3177
+ # place to read it from without a second manifest parse.
3178
+ zephyr = facts.get("zephyr")
3179
+ if isinstance(zephyr, dict) and isinstance(zephyr.get("pythonMinVersion"), str):
3180
+ prerequisites = {
3181
+ **prerequisites,
3182
+ "_zephyrPythonMinVersion": zephyr["pythonMinVersion"],
3183
+ }
3184
+ # tan-cli#1066: `artifactProvenance` is TOP-LEVEL in the manifest
3185
+ # (alp-sdk#1574) while everything else this reader returns lives under
3186
+ # `prerequisites` -- injected onto the returned dict the same way
3187
+ # `_pipSpec` and `_zephyrPythonMinVersion` are, so `prerequisites_check`
3188
+ # has one place to read it from and this file keeps its single parse.
3189
+ # ALWAYS injected, even when absent/malformed: `parse_table` answers `{}`
3190
+ # for both, and `{}` is the same "nothing reported" a tool with no entry
3191
+ # gets -- so no caller has to branch on whether the key was there.
3192
+ prerequisites = {
3193
+ **prerequisites,
3194
+ "_artifactProvenance": artifact_provenance.parse_table(
3195
+ facts.get(artifact_provenance.BLOCK_KEY)
3196
+ ),
3197
+ }
3198
+ return ManifestLoad(
3199
+ prerequisites,
3200
+ f"facts from alp-sdk {path}",
3201
+ None,
3202
+ is_real=True,
3203
+ # Reported, never refused: a corrupt provenance block must not cost
3204
+ # this customer their diagnosis (tan-cli#1066), and must not cost the
3205
+ # NEXT one the ability to see that alp-sdk's generator regressed
3206
+ # (#1066 review -- a silent degrade is byte-identical to a pre-v0.16.0
3207
+ # SDK, so the producer bug is undetectable from here).
3208
+ provenance_error=artifact_provenance.problems_in(facts),
3209
+ )
3210
+
3211
+
3212
+ def _manifest_floor_from_facts(facts: dict) -> tuple[int, int]:
3213
+ """The `pythonMinVersion` `facts` declares, or `FALLBACK_PYTHON_FLOOR` when
3214
+ absent/unparseable -- shared by `_collect` and `resolve_manifest_python_floor`
3215
+ so the two never parse the same field two different ways."""
3216
+ return _parse_two(str(facts.get("pythonMinVersion") or "")) or FALLBACK_PYTHON_FLOOR
3217
+
3218
+
3219
+ def _zephyr_manifest_floor_from_facts(facts: dict) -> tuple[int, int] | None:
3220
+ """The `zephyr.pythonMinVersion` `facts` declares (tan-cli#606), or `None`
3221
+ when absent/unparseable/no manifest resolved at all -- callers fall back
3222
+ to `ZEPHYR_PYTHON_FLOOR` in that case, same as before this field existed.
3223
+ Unlike `_manifest_floor_from_facts`, `None` is a normal result, not a
3224
+ degraded one: most manifests on disk today predate this key."""
3225
+ raw = facts.get("_zephyrPythonMinVersion")
3226
+ if not isinstance(raw, str):
3227
+ return None
3228
+ return _parse_two(raw)
3229
+
3230
+
3231
+ def resolve_manifest_python_floor(sdk_root: str | None) -> tuple[tuple[int, int], str]:
3232
+ """`(floor, provenance)` for the SDK's OWN declared Python floor --
3233
+ `<sdk>/metadata/bootstrap.json`'s `prerequisites.pythonMinVersion` -- for
3234
+ callers gating a SPAWNED SDK interpreter (`generate`/`model`) rather than a
3235
+ Zephyr build, so they want this floor, not `_collect`'s Zephyr-composed
3236
+ effective one. The ONE reader: before this, `generate_cmd` and `model_cmd`
3237
+ each carried their own hardcoded `MIN_PYTHON = (3, 10)`, a floor that could
3238
+ drift from the manifest's -- and from each other's -- without either
3239
+ command noticing.
3240
+ """
3241
+ loaded = _load_manifest(sdk_root)
3242
+ return _manifest_floor_from_facts(loaded.facts), loaded.source
3243
+
3244
+
3245
+ #: Generous on purpose: a real install can pull a package over the network,
3246
+ #: unlike every OTHER timeout in this file (`PROBE_TIMEOUT_S`), which only
3247
+ #: ever waits on a local `--version` banner. `ponytail`: one fixed ceiling,
3248
+ #: no live progress WITHIN a single install -- `run_fix`'s `on_check` streams
3249
+ #: a Check the MOMENT one tool's own outcome is known, which closes the blank
3250
+ #: window BETWEEN tools, not the window a single slow install itself still
3251
+ #: opens; raise this ceiling, or stream that install's own output, if one
3252
+ #: exceeds it before this is revisited.
3253
+ FIX_INSTALL_TIMEOUT_S = 300
3254
+
3255
+
3256
+ def fix_needs_sudo_check(tool: str, command: str) -> Check:
3257
+ """`doctor.fix-needs-sudo` -- ADR 0021's Tier-B refusal (tan-cli#91,
3258
+ MAINTAINER DECISION): tan never spawns the `sudo` PROGRAM on the
3259
+ customer's behalf.
3260
+
3261
+ Under `--format json` this process's stdio is captured end to end, so a
3262
+ `sudo` password prompt has nowhere to go -- it would hang forever rather
3263
+ than fail loudly, which is a worse outcome than refusing up front. REFUSE
3264
+ AND PRINT: name the exact command, verbatim, so it can be pasted into a
3265
+ real terminal, and stop there. `run_fix` below is the only caller, and
3266
+ only reaches this branch for a command whose first word IS literally
3267
+ `sudo` -- the manifest's own POSIX `prerequisites.install` commands are
3268
+ the one place that word appears in this codebase at all; Windows
3269
+ (`winget`, user-scope) and macOS (`brew`) never need it.
3270
+
3271
+ tan-cli#650: `run_fix` only reaches THIS branch when the caller is NOT
3272
+ already root -- a root caller has nothing left to elevate to, and
3273
+ `run_fix` strips the literal `sudo ` word and runs the manifest's own
3274
+ remaining command directly instead of calling this function at all. That
3275
+ still never spawns the `sudo` program (the MAINTAINER DECISION above is
3276
+ about the program, not the elevation it grants), so a root caller getting
3277
+ real remediation here does not contradict it -- it recognises that
3278
+ "needs elevation" is already satisfied when the process asking is root.
3279
+ """
3280
+ return Check(
3281
+ f"fix:{tool}",
3282
+ "warn",
3283
+ f'`--fix` will not run `{command}` for {tool}: it needs elevation '
3284
+ f'("sudo"), and tan never spawns sudo itself. Run it yourself, then '
3285
+ f"re-run `tan doctor`.",
3286
+ command,
3287
+ code="doctor.fix-needs-sudo",
3288
+ scope="host",
3289
+ )
3290
+
3291
+
3292
+ def fix_installed_check(
3293
+ tool: str, command: str, *, elevation_skipped: bool = False
3294
+ ) -> Check:
3295
+ """`doctor.fix-installed` -- `--fix` ran a manifest install command that
3296
+ needed no elevation (ADR 0021 Tier A), and the child process exited 0.
3297
+
3298
+ Deliberately NOT a claim that `{tool}` is now on PATH: this process
3299
+ already read its own PATH at start-up (tan-cli#91), so an install that
3300
+ lands after that moment is invisible to it -- there is no same-process
3301
+ re-check to perform, honestly or otherwise. "Installed; reopen your
3302
+ shell" is the whole truth this check can tell; `hostPrerequisites`
3303
+ above still reports `{tool}` missing in THIS report, which is correct
3304
+ for THIS report.
3305
+
3306
+ `elevation_skipped` (tan-cli#650): set when `command` is the manifest's
3307
+ own line with its literal `sudo ` prefix already removed by `run_fix`,
3308
+ because the caller was ALREADY root -- there was no elevation left to
3309
+ acquire. Said explicitly so this reads as the root-aware Tier-B path it
3310
+ is, not as an ordinary Tier-A ("this tool never needed elevation at
3311
+ all") outcome; the two are true for different reasons and a reader
3312
+ deciding whether to trust `--fix` on their next root container should be
3313
+ able to tell them apart.
3314
+ """
3315
+ lede = (
3316
+ f"`--fix` ran `{command}` for {tool} (already running as root, so "
3317
+ f"the manifest's `sudo` was not needed and was not run)."
3318
+ if elevation_skipped
3319
+ else f"`--fix` ran `{command}` for {tool}."
3320
+ )
3321
+ return Check(
3322
+ f"fix:{tool}",
3323
+ "warn",
3324
+ f"{lede} tan cannot see a PATH change made after it started -- open "
3325
+ f"a new shell, then re-run `tan doctor` there to confirm.",
3326
+ code="doctor.fix-installed",
3327
+ scope="host",
3328
+ )
3329
+
3330
+
3331
+ def fix_spawn_failed_check(tool: str, command: str, err: Exception) -> Check:
3332
+ """`doctor.fix-spawn-failed` -- `--fix` resolved `{tool}`'s install
3333
+ command on PATH (`on_path` already succeeded) but starting it raised
3334
+ (`OSError`/`ValueError`/`subprocess.SubprocessError` other than a
3335
+ timeout). Distinct from silence: without this, a customer watching
3336
+ `--fix` do nothing cannot tell "the OS refused to start it" from "tan
3337
+ never tried"."""
3338
+ return Check(
3339
+ f"fix:{tool}",
3340
+ "warn",
3341
+ f"`--fix` could not start `{command}` for {tool}: {err}. Run it "
3342
+ f"yourself, then re-run `tan doctor`.",
3343
+ command,
3344
+ code="doctor.fix-spawn-failed",
3345
+ scope="host",
3346
+ )
3347
+
3348
+
3349
+ def fix_failed_check(tool: str, command: str, returncode: int) -> Check:
3350
+ """`doctor.fix-failed` -- `--fix` ran `{tool}`'s install command and the
3351
+ child exited non-zero. `hostPrerequisites` above still reports `{tool}`
3352
+ missing in THIS report (same no-same-process-recheck honesty as
3353
+ `fix_installed_check`) -- this Check is the only place a customer learns
3354
+ the install itself failed, rather than merely "still missing"."""
3355
+ return Check(
3356
+ f"fix:{tool}",
3357
+ "warn",
3358
+ f"`--fix` ran `{command}` for {tool}; it exited {returncode}. Run it "
3359
+ f"yourself to see the full output, then re-run `tan doctor`.",
3360
+ command,
3361
+ code="doctor.fix-failed",
3362
+ scope="host",
3363
+ )
3364
+
3365
+
3366
+ def fix_timed_out_check(tool: str, command: str) -> Check:
3367
+ """`doctor.fix-timed-out` -- `{tool}`'s install command did not finish
3368
+ inside `FIX_INSTALL_TIMEOUT_S` (300s) and was killed. Without this, a
3369
+ hang here reads as up to 20 minutes of silent terminal: text-mode output
3370
+ only prints after the WHOLE report completes."""
3371
+ return Check(
3372
+ f"fix:{tool}",
3373
+ "warn",
3374
+ f"`--fix` killed `{command}` for {tool} after {FIX_INSTALL_TIMEOUT_S}s "
3375
+ f"with no result. Run it yourself, then re-run `tan doctor`.",
3376
+ command,
3377
+ code="doctor.fix-timed-out",
3378
+ scope="host",
3379
+ )
3380
+
3381
+
3382
+ #: Per-installer remedy for `fix_installer_not_found_check`, keyed by the first
3383
+ #: word of the command actually resolved and spawned -- for a `sudo`-prefixed
3384
+ #: manifest line that is the word AFTER `sudo` (tan-cli#650 strips it for an
3385
+ #: already-root caller before this table is ever consulted; a non-root caller
3386
+ #: is refused by `fix_needs_sudo_check` long before this point, so `sudo`
3387
+ #: itself never appears here as a key). Two named entries: `metadata/
3388
+ #: bootstrap.json`'s `prerequisites.install` produces `brew` on macOS and
3389
+ #: `winget` on Windows (see `_fallback_install_commands` in `tan.core.
3390
+ #: bootstrap`, which is byte-pinned to the manifest). Anything else --
3391
+ #: including `apt-get`, on the rare root-container host missing it -- gets
3392
+ #: the generic remedy below: a manifest is free to name a package manager
3393
+ #: this table has never heard of, and "not found" with no advice is the
3394
+ #: exact silence tan-cli#360 is about.
3395
+ INSTALLER_REMEDIES = {
3396
+ "brew": (
3397
+ "Homebrew is not installed -- install it from https://brew.sh, then "
3398
+ "re-run `tan doctor --fix`."
3399
+ ),
3400
+ "winget": (
3401
+ 'winget comes from the App Installer package -- install "App '
3402
+ 'Installer" from the Microsoft Store (Windows 10 1809 or newer), open '
3403
+ "a new shell so PATH picks it up, then re-run `tan doctor --fix`."
3404
+ ),
3405
+ }
3406
+
3407
+
3408
+ def fix_installer_not_found_check(installer: str, tools: list[str]) -> Check:
3409
+ """`doctor.fix-installer-not-found` -- tan-cli#360: `--fix` could not
3410
+ resolve the program the manifest's install command starts with, so it ran
3411
+ NO repair for the tools that command covers.
3412
+
3413
+ This used to be a bare `continue` in `run_fix` -- the one outcome that
3414
+ emitted no Check at all, in a function whose whole stated invariant is
3415
+ that every entry is either run or refused and every outcome is reported.
3416
+ It is also the single most likely outcome on the hosts `--fix` exists
3417
+ for: the manifest installs with `brew` on macOS and `winget` on Windows,
3418
+ so a fresh Mac without Homebrew, or a Windows image with no usable
3419
+ winget, reported tools missing, ACCEPTED `--fix`, and then printed
3420
+ exactly the same report back with no `fix:*` line anywhere. The
3421
+ least-equipped host got the least diagnostic behaviour, and could not
3422
+ tell "nothing needed fixing" from "tan never found the installer to try
3423
+ with".
3424
+
3425
+ ONE Check per installer, not per tool: a fresh Mac is missing all six
3426
+ manifest prerequisites at once and every one of them says `brew`, so
3427
+ per-tool would print the same "install Homebrew" paragraph six times over
3428
+ -- six restatements of one fact, in a report a customer is reading to
3429
+ find out what is actually wrong. `tools` therefore carries every tool
3430
+ this one absence blocked, and `run_fix` groups them.
3431
+
3432
+ Named `fix:{installer}` rather than `fix:{tool}` for the same reason --
3433
+ the subject of this verdict is the installer, and one name per absent
3434
+ installer is what keeps the grouping legible in the text report. No
3435
+ `fix` field, and no "see `hostPrerequisites` above" either (tan-cli#760
3436
+ review round 3, MINOR): the per-tool commands are not runnable until the
3437
+ installer exists, and this Check fires precisely when that installer is
3438
+ unconfirmed -- the exact condition under which `hostPrerequisites.
3439
+ missing[].command` is `null` too, so pointing at it would be false on
3440
+ the one host this Check describes.
3441
+ """
3442
+ named = ", ".join(tools)
3443
+ many = len(tools) > 1
3444
+ remedy = INSTALLER_REMEDIES.get(installer) or (
3445
+ f"Install `{installer}`, then re-run `tan doctor --fix`."
3446
+ )
3447
+ return Check(
3448
+ f"fix:{installer}",
3449
+ "warn",
3450
+ f"`--fix` ran no repair for {named}: the manifest installs "
3451
+ f"{'them' if many else 'it'} with `{installer}`, which is not on "
3452
+ f"PATH. {remedy} Or install {'those tools' if many else named} with a "
3453
+ f"package manager this host already has.",
3454
+ code="doctor.fix-installer-not-found",
3455
+ scope="host",
3456
+ )
3457
+
3458
+
3459
+ def _running_as_root() -> bool:
3460
+ """tan-cli#650: whether THIS process's effective UID is 0 -- POSIX only.
3461
+
3462
+ `os.geteuid` does not exist on Windows, where no manifest install
3463
+ command ever carries a `sudo` prefix in the first place (`winget` is
3464
+ user-scope) -- see `fix_needs_sudo_check`'s docstring -- so this is only
3465
+ ever consulted from the one platform whose manifest can raise the
3466
+ question at all, and `getattr` guards the call rather than an `if
3467
+ sys.platform` branch so this stays correct if that ever changes.
3468
+
3469
+ The caller this exists for: a root container (`docker run`, most CI base
3470
+ images, a fresh cloud VM before its first `useradd`) running `tan doctor
3471
+ --build --fix`. There is no elevation left to acquire there, so
3472
+ `run_fix` refusing every `sudo`-prefixed manifest command unconditionally
3473
+ -- which it did before this -- was not caution, it was a false negative:
3474
+ measured live via a real `geteuid() == 0` process (`unshare --user
3475
+ --map-root-user`, a genuine root user namespace, since this box has no
3476
+ passwordless real root), `run_fix` still emitted `doctor.fix-needs-sudo`
3477
+ and ran nothing.
3478
+ """
3479
+ geteuid = getattr(os, "geteuid", None)
3480
+ return geteuid is not None and geteuid() == 0
3481
+
3482
+
3483
+ def run_fix(
3484
+ missing: list[dict[str, str | int | None]],
3485
+ on_check: Callable[[Check], None] | None = None,
3486
+ ) -> list[Check]:
3487
+ """`--fix`'s ADR 0021 executor (tan-cli#91): for each tool
3488
+ `hostPrerequisites` already reported missing, either run its manifest
3489
+ install command (no elevation needed -- Tier A) or refuse and name it
3490
+ (needs `sudo` -- Tier B), never both, never neither. `missing` is that
3491
+ check's OWN structured field -- but `Check.fix_missing`, NOT `Check.
3492
+ missing` (tan-cli#760 review round 3, MINOR: this used to say `Check.
3493
+ missing`, which stopped being true the moment tan-cli#760 split the two).
3494
+ `fix_missing` is the RAW twin: `missing` (`data.missingPrerequisites`,
3495
+ the envelope) nulls a command whose own installer this host cannot
3496
+ confirm, and feeding THAT to `run_fix` would silently disable the Tier B
3497
+ diagnosis below the moment a tool's command is unconfirmed -- the exact
3498
+ regression tan-cli#760's own review caught. Never a second,
3499
+ independently recomputed tool/command list either way, so this can only
3500
+ ever act on exactly what `prerequisites_check` already computed.
3501
+
3502
+ A tool with `command=None` (the manifest names no install command for
3503
+ it) is skipped outright: nothing to run, nothing to refuse, and the
3504
+ existing `hostPrerequisites` Fail already carries the honest "install it
3505
+ yourself" advice for that case.
3506
+
3507
+ Every outcome becomes a `Check` -- `fix_needs_sudo_check`/
3508
+ `fix_installed_check` on the two "acted, and it's fine" paths, (as of
3509
+ the tan-cli#91 follow-up below) `fix_spawn_failed_check`/`fix_failed_check`/
3510
+ `fix_timed_out_check` on the three "acted, and it's NOT fine" paths, and
3511
+ (tan-cli#360) `fix_installer_not_found_check` on the "could not act at
3512
+ all" one -- never a bare side effect. A customer who typed `--fix` and
3513
+ got the SAME report back used to have no way to tell "nothing needed
3514
+ fixing" from "tan tried and silently gave up": a spawn error, a non-zero
3515
+ exit, a `FIX_INSTALL_TIMEOUT_S` (300s) timeout, or an install command
3516
+ whose own program is not on PATH each used to `continue` with no trace at
3517
+ all. `hostPrerequisites`'s own Fail still names the tool and its command
3518
+ either way; these Checks add the ONE fact it structurally cannot carry --
3519
+ what `--fix` itself did about it.
3520
+
3521
+ `on_check` -- same shape, same job as `_collect`'s parameter of that
3522
+ name: fired the MOMENT one tool's own outcome is known, not after every
3523
+ tool has been tried. Before this, `--fix`'s checks streamed only once
3524
+ `run_fix` returned IN FULL -- the LARGEST blank-terminal window in the
3525
+ whole command, `FIX_INSTALL_TIMEOUT_S` (300s) times up to 4 tools,
3526
+ against `PROBE_TIMEOUT_S` (15s) for the probes `_collect` already
3527
+ streamed. `doctor()` passes it the SAME `_print_check` it gives
3528
+ `_collect`, so a `--fix` outcome prints through the identical guarded
3529
+ `_print_stream_lines` path (see its docstring) as every other check in
3530
+ this report.
3531
+
3532
+ Only ever called from `doctor()`'s `--fix` branch, itself gated on
3533
+ `can_prompt` (`tan.core.consent`) -- the one place in this module that
3534
+ mutates the host rather than merely observing it, so it is confined
3535
+ exactly there, never folded into `_collect` (pure probes, see the module
3536
+ docstring).
3537
+ """
3538
+ results: list[Check] = []
3539
+
3540
+ def _add(check: Check) -> None:
3541
+ # Mirrors `_collect`'s own `_add` closure exactly -- append, then
3542
+ # stream, so a caller with no `on_check` (every direct unit-test
3543
+ # caller of this function) sees no behaviour change at all.
3544
+ results.append(check)
3545
+ if on_check is not None:
3546
+ on_check(check)
3547
+
3548
+ # installer -> every tool whose install command starts with it and that
3549
+ # therefore went unrepaired (tan-cli#360). Insertion-ordered, so the
3550
+ # grouped Checks appended below come out in `missing` order rather than
3551
+ # an arbitrary one.
3552
+ unresolved: dict[str, list[str]] = {}
3553
+ for entry in missing:
3554
+ tool = entry.get("tool")
3555
+ command = entry.get("command")
3556
+ if not tool or not command:
3557
+ continue
3558
+ # tan-cli#650: `effective_command` is what actually gets resolved and
3559
+ # spawned below; `command` (the manifest's own, untouched line) is
3560
+ # kept only for `fix_needs_sudo_check`'s refusal message, which must
3561
+ # name the exact line a human would paste into a real terminal.
3562
+ effective_command = command
3563
+ elevation_skipped = False
3564
+ if command.strip().startswith("sudo "):
3565
+ if not _running_as_root():
3566
+ _add(fix_needs_sudo_check(tool, command))
3567
+ continue
3568
+ # Already root: there is nothing left to elevate to, and this
3569
+ # process still never spawns the `sudo` PROGRAM (see
3570
+ # `fix_needs_sudo_check`'s docstring) -- it strips the literal
3571
+ # `sudo ` word and runs the manifest's OWN remaining command
3572
+ # directly, the same way every non-elevated entry already does.
3573
+ effective_command = command.strip()[len("sudo ") :].lstrip()
3574
+ elevation_skipped = True
3575
+ argv = shlex.split(effective_command)
3576
+ if not argv:
3577
+ # The one deliberately silent skip left (tan-cli#360 reviewed the
3578
+ # rest): an all-whitespace install command names no program to
3579
+ # report as absent, so there is nothing this could say beyond what
3580
+ # `hostPrerequisites`'s own Fail already says about the tool. A
3581
+ # manifest defect, not a host one.
3582
+ continue
3583
+ # `on_path`, never bare `subprocess.run([name, ...])`: the same
3584
+ # PATH-only, no-cwd-insertion resolver every other spawn in this
3585
+ # module uses (see `on_path`'s own docstring) -- a project-local
3586
+ # binary happening to share the tool's name must not be what `--fix`
3587
+ # runs with elevated-sounding trust.
3588
+ resolved_exe = on_path(argv[0])
3589
+ if resolved_exe is None:
3590
+ # tan-cli#360: collected, not silently dropped. Grouped by
3591
+ # installer because one absent `brew` blocks every macOS
3592
+ # prerequisite at once -- see `fix_installer_not_found_check`.
3593
+ unresolved.setdefault(argv[0], []).append(tool)
3594
+ continue
3595
+ argv[0] = resolved_exe
3596
+ try:
3597
+ result = subprocess.run(
3598
+ argv,
3599
+ capture_output=True,
3600
+ text=True,
3601
+ encoding="utf-8",
3602
+ errors="replace",
3603
+ stdin=subprocess.DEVNULL,
3604
+ timeout=FIX_INSTALL_TIMEOUT_S,
3605
+ env=spawn_env(),
3606
+ check=False,
3607
+ )
3608
+ except subprocess.TimeoutExpired:
3609
+ _add(fix_timed_out_check(tool, effective_command))
3610
+ continue
3611
+ except (OSError, ValueError, subprocess.SubprocessError) as err:
3612
+ _add(fix_spawn_failed_check(tool, effective_command, err))
3613
+ continue
3614
+ if result.returncode == 0:
3615
+ _add(
3616
+ fix_installed_check(
3617
+ tool, effective_command, elevation_skipped=elevation_skipped
3618
+ )
3619
+ )
3620
+ else:
3621
+ _add(fix_failed_check(tool, effective_command, result.returncode))
3622
+ for installer, tools in unresolved.items():
3623
+ _add(fix_installer_not_found_check(installer, tools))
3624
+ return results
3625
+
3626
+
3627
+ def fix_suppressed_issue(*, non_interactive: bool, ci: bool, json_mode: bool) -> Issue:
3628
+ """`doctor.fix-suppressed` -- tan-cli#91 P1, measured against the oracle:
3629
+ `tan doctor --fix --format json` on an unhealthy host used to be a
3630
+ byte-for-byte silent no-op vs. plain `tan doctor` -- no issue, no note,
3631
+ exit code unchanged -- indistinguishable from a `--fix` that genuinely
3632
+ found nothing to do. The oracle's own equivalent refuses outright
3633
+ (`cli.parse-error`, exit 2); this port instead reports HONESTLY: `--fix`
3634
+ was requested, the `can_prompt` consent gate (`tan.core.consent`) refused
3635
+ it, and here is which of its conditions actually tripped -- not just that
3636
+ nothing happened.
3637
+
3638
+ Only ever called from `doctor()`, and only when `fix` is set and
3639
+ `can_prompt` returned `False` for these same three flags -- never the
3640
+ other way around, so this can only ever explain a REAL suppression.
3641
+
3642
+ The `isatty()` pair is read ONLY when `not json_mode`, mirroring
3643
+ `can_prompt`'s own short-circuit order (`... and not json_mode and
3644
+ sys.stdin.isatty() and sys.stderr.isatty()`) rather than a coincidence:
3645
+ under `--format json`, `tan.cli.main` tees `sys.stderr` through
3646
+ `_TeeStderr`, which has no `isatty()` at all -- reading it unconditionally
3647
+ here crashes this exact suppressed-fix report with
3648
+ `AttributeError: '_TeeStderr' object has no attribute 'isatty'` (measured
3649
+ against a real `tan doctor --fix --format json --ci` run). `json_mode`
3650
+ is already a complete, accurate reason on its own; there is nothing the
3651
+ tty state could add under it.
3652
+
3653
+ tan-cli#488 defect 6: `sys.stdin` is guarded with `is not None` before
3654
+ `.isatty()` -- `sys.stdin` itself, not just `sys.stderr`, can be `None`
3655
+ (a GUI-launched/`pythonw`-style process, or any host that runs tan with
3656
+ its standard handles detached), and a bare `sys.stdin.isatty()` there
3657
+ raises `AttributeError: 'NoneType' object has no attribute 'isatty'` for
3658
+ the same reason the `_TeeStderr` case above already gets its own guard.
3659
+
3660
+ tan-cli#488 round 3: this was, for one round, the ONLY place that guard
3661
+ existed -- `can_prompt` itself (`tan.core.consent`) still called the bare
3662
+ `sys.stdin.isatty()`. Since `doctor()` calls `can_prompt` BEFORE it ever
3663
+ calls this function (`fix_allowed = fix and can_prompt(...)`, evaluated
3664
+ ahead of the `fix_suppressed_issue(...)` call site below it), a `None`
3665
+ `sys.stdin` crashed there first, and this function's own guard never ran
3666
+ at all -- caught only by `doctor()`'s outer `except Exception`, which
3667
+ discarded the whole diagnosis. `can_prompt` now carries the identical
3668
+ guard (the fix belongs there, once, not duplicated per caller -- see its
3669
+ own docstring); the copy here stays only because this function explains
3670
+ each tripped condition individually, not merely whether the aggregate
3671
+ gate passed.
3672
+
3673
+ tan-cli#488 round 5: `sys.stderr`, the NEXT operand of this same `and`
3674
+ chain, was left unguarded through rounds 3 and 4 -- `sys.stderr` can be
3675
+ `None` independently of `sys.stdin` (each handle is detached on its own
3676
+ by whatever spawned the process), so a host with a live, non-tty stdin
3677
+ and a detached stderr still crashed here with the identical
3678
+ `AttributeError`, one operand later. `can_prompt` now guards both; this
3679
+ copy must match it exactly or the two diverge on the very host this
3680
+ function exists to explain.
3681
+
3682
+ tan-cli#488 round 6: `is not None` still was not the whole guard -- it
3683
+ stops a detached (`None`) handle from crashing `.isatty()`, but not a
3684
+ handle that EXISTS and simply has no `.isatty()` method, exactly the
3685
+ shape `sys.stderr` takes under `--format json` (`tan.cli.main`'s
3686
+ `_TeeStderr`, which implements `write`/`flush`/`getvalue` only). The
3687
+ `not json_mode` guard in front of this whole condition means this
3688
+ particular copy was never observed to reach that shape in practice --
3689
+ but `tan.commands.build_cmd._dispatch` hand-rolled the identical
3690
+ `is not None and .isatty()` pair with no `json_mode` operand ahead of
3691
+ it, and that one DID crash on a real `tan run --format json`
3692
+ (`AttributeError: '_TeeStderr' object has no attribute 'isatty'`,
3693
+ measured), one caller away from the same fate here. Both operands now
3694
+ route through `tan.env.stdin_is_tty`/`stderr_is_tty` -- the one shared
3695
+ probe (tan-cli#288) already used by `use_color`/`wrap_width` above and
3696
+ now by `can_prompt` too -- instead of a fourth hand-rolled copy of a
3697
+ guard three of the previous four rounds already got wrong once.
3698
+ """
3699
+ reasons = []
3700
+ if json_mode:
3701
+ reasons.append("`--format json` (no terminal to prompt on)")
3702
+ if ci:
3703
+ reasons.append("`--ci`")
3704
+ if non_interactive:
3705
+ reasons.append("`--non-interactive`")
3706
+ if not json_mode and not (stdin_is_tty() and stderr_is_tty()):
3707
+ reasons.append("no interactive terminal (stdin/stderr not a tty -- piped, redirected, or CI)")
3708
+ return Issue(
3709
+ "doctor.fix-suppressed",
3710
+ "warning",
3711
+ "`--fix` was requested but not run: " + "; ".join(reasons) + ". Re-run "
3712
+ "`tan doctor --fix` from a real, interactive terminal, without "
3713
+ "--ci/--non-interactive/--format json, to allow it.",
3714
+ )
3715
+
3716
+
3717
+ @dataclass(frozen=True)
3718
+ class _PrerequisitesEnvironment:
3719
+ """Everything `bootstrapManifest`/`hostPrerequisites` (and `_collect`'s
3720
+ OWN `hostPython`/`pythonFloor`/`west` checks, which read the SAME
3721
+ manifest load and Python probe) need, computed exactly once regardless of
3722
+ which caller asked -- see `_resolve_prerequisites_environment` (tan-cli#441).
3723
+ """
3724
+
3725
+ bootstrap_manifest_check: Check | None
3726
+ prerequisites_check: Check
3727
+ facts: dict
3728
+ manifest_floor: tuple[int, int]
3729
+ effective_floor: tuple[int, int]
3730
+ effective_source: str
3731
+ python_found: tuple[str, tuple[int, int], str] | None
3732
+ manifest_is_real: bool
3733
+ #: Every interpreter the ONE probe saw (tan-cli#1317) -- `hostPython`'s
3734
+ #: build-readiness verdict reads this instead of probing a second time.
3735
+ host_pythons: tuple = ()
3736
+
3737
+
3738
+ def _resolve_prerequisites_environment(
3739
+ sdk_root: str | None, workspace_path: Path | None
3740
+ ) -> _PrerequisitesEnvironment:
3741
+ """The manifest load through `hostPrerequisites` -- moved verbatim out of
3742
+ `_collect` (tan-cli#441) so `host_environment_checks` (support-bundle's
3743
+ seam onto exactly these two checks; see its own docstring) and `_collect`
3744
+ (`tan doctor`'s full checklist, which ALSO needs `hostPython`/
3745
+ `pythonFloor`/`west`'s `facts.get("_pipSpec")` off the SAME manifest load
3746
+ and the SAME Python probe) build `bootstrapManifest`/`hostPrerequisites`
3747
+ from ONE copy of this logic and pay for the manifest read + the
3748
+ `_probe_host_python` subprocess spawn exactly once per caller, instead of
3749
+ a second copy drifting the moment either check changes.
3750
+
3751
+ `workspace_path` is a parameter, not resolved here, so a caller that
3752
+ already has one (`_collect`, from its own earlier `west_workspace_dir`
3753
+ call) never pays for a second walk -- `host_environment_checks` below
3754
+ resolves its own before calling this.
3755
+ """
3756
+ loaded = _load_manifest(sdk_root)
3757
+ facts, source = loaded.facts, loaded.source
3758
+ bootstrap_manifest_check: Check | None = None
3759
+ if loaded.error is not None:
3760
+ bootstrap_manifest_check = Check(
3761
+ "bootstrapManifest",
3762
+ "warn",
3763
+ f"metadata/bootstrap.json rejected: {loaded.error}. Falling back to "
3764
+ f"tan's built-in prerequisite list, which may not match this SDK.",
3765
+ "Update `tan` or pin an SDK whose metadata/bootstrap.json this "
3766
+ "version understands; `tan bootstrap` will refuse outright until then.",
3767
+ scope="project",
3768
+ )
3769
+ elif loaded.provenance_error is not None:
3770
+ # tan-cli#1066 review. SAME check name and SAME issue code as the
3771
+ # rejected-manifest arm above -- a consumer keying on
3772
+ # `doctor.bootstrap-manifest` sees both -- but its own wording, because
3773
+ # this manifest was NOT rejected: the tool list, the floor and the
3774
+ # install commands were all read normally and `is_real` is true, so
3775
+ # borrowing the "falling back to tan's built-in prerequisite list"
3776
+ # sentence would send the reader hunting a staleness that does not
3777
+ # exist. `warn`, never `fail`: the exit code and every reported entry
3778
+ # are byte-identical to a run with no provenance at all (the four keys
3779
+ # are still there, still `null`) -- what changes is that the failure is
3780
+ # now SAYABLE. `elif`: a manifest that was rejected outright never had
3781
+ # its provenance parsed in the first place, and the bigger fact wins.
3782
+ bootstrap_manifest_check = Check(
3783
+ "bootstrapManifest",
3784
+ "warn",
3785
+ f"metadata/bootstrap.json was read, but its artifactProvenance "
3786
+ f"block is not readable: {loaded.provenance_error}. Any affected "
3787
+ f"missingPrerequisites[] entries report tier/licence/sourceUrl/"
3788
+ f"sizeBytes as null; the prerequisite list itself is unaffected.",
3789
+ "Report this against alp-sdk -- its metadata/bootstrap.json "
3790
+ "generator produced the block, and `tan` needs no change. Nothing "
3791
+ "else in this report is affected.",
3792
+ scope="project",
3793
+ )
3794
+
3795
+ manifest_floor = _manifest_floor_from_facts(facts)
3796
+ # tan-cli#301 (second half): read the SAME resolved workspace `zephyrWorkspace`
3797
+ # reports above (`workspace_path`, from the shared `west_workspace_dir`) --
3798
+ # NOT a second, independent `$ZEPHYR_BASE` read. A stale exported
3799
+ # `$ZEPHYR_BASE` is common (Zephyr's own docs, and this command's own `tan
3800
+ # bootstrap` next-steps block, both tell a customer to export it), and
3801
+ # reading it here regardless of the resolved workspace is how one report
3802
+ # ended up citing two different Zephyrs. `$ZEPHYR_BASE` is consulted only as
3803
+ # `zephyr_python_floor`'s fallback, when no workspace resolved at all --
3804
+ # mirroring #290's fix for `zephyrWorkspace` itself.
3805
+ zephyr_source_base = (
3806
+ str(workspace_path / "zephyr")
3807
+ if workspace_path is not None
3808
+ else os.environ.get("ZEPHYR_BASE")
3809
+ )
3810
+ # tan-cli#606: when no workspace resolves, prefer alp-sdk's OWN declared
3811
+ # `zephyr.pythonMinVersion` over `ZEPHYR_PYTHON_FLOOR` -- same `facts`
3812
+ # dict `manifest_floor` above already reads, so this is not a second
3813
+ # manifest parse.
3814
+ zephyr_manifest_floor = _zephyr_manifest_floor_from_facts(facts)
3815
+ # tan tan-cli#1317: the ONE composition (`tan.core.python_floor`), shared
3816
+ # with `tan build`'s interpreter pick.
3817
+ effective_floor, effective_source = effective_python_floor(
3818
+ manifest_floor, zephyr_source_base, zephyr_manifest_floor
3819
+ )
3820
+
3821
+ host_pythons = probe_all_host_pythons()
3822
+ python_found = _probe_host_python(effective_floor, host_pythons)
3823
+
3824
+ # tan-cli#488 defect 2: keyed on the HOST, not a `windows`/`posix` bool.
3825
+ # `facts.get(...)` used to read straight off `"windows" if os.name == "nt"
3826
+ # else "posix"`, which has no `macos` arm at all -- so a stock Mac (no
3827
+ # `wget`, no standalone `xz`, both of which joined `prerequisites.posix`
3828
+ # at alp-sdk v0.14.0) got handed the POSIX list wholesale and reported
3829
+ # `hostPrerequisites: fail` on a host `tan bootstrap` accepts. Mirrors
3830
+ # `tan.core.bootstrap.BootstrapFacts.prerequisites`'s own host-keyed read
3831
+ # exactly, including its fallback: an EMPTY/absent `prerequisites.macos`
3832
+ # means the manifest declared none (every SDK before v0.14.0), and macOS
3833
+ # then reads `posix` exactly as it always did -- not a guess, the actual
3834
+ # pre-v0.14.0 behaviour.
3835
+ is_macos = sys.platform == "darwin"
3836
+ required = facts.get("windows" if os.name == "nt" else ("macos" if is_macos else "posix"))
3837
+ if is_macos and not (isinstance(required, list) and required):
3838
+ required = facts.get("posix")
3839
+ if not isinstance(required, list):
3840
+ required = []
3841
+ required = [t for t in required if isinstance(t, str)]
3842
+ install = facts.get("install")
3843
+ platform_key = "windows" if os.name == "nt" else ("macos" if sys.platform == "darwin" else "linux")
3844
+ if platform_key == "linux":
3845
+ # `install["linux"]` is package-manager-keyed, not tool-keyed like
3846
+ # `macos`/`windows` (alp-sdk#1464 / tan-cli#760's second half) --
3847
+ # `facts` is the RAW parsed manifest dict (`_load_manifest`), a
3848
+ # separate reader from `tan.core.bootstrap.parse_bootstrap_manifest`,
3849
+ # so it needs the SAME normalize/select reconciliation that reader
3850
+ # applies, not a flat `.get(platform_key)` (which would silently
3851
+ # return `{}` for every Linux tool, apt included).
3852
+ linux_pm = detect_linux_pm(lambda binary: on_path(binary) is not None)
3853
+ per_tool = select_linux_install(
3854
+ normalize_linux_install(install.get("linux") if isinstance(install, dict) else None),
3855
+ linux_pm,
3856
+ )
3857
+ else:
3858
+ per_tool = install.get(platform_key) if isinstance(install, dict) else None
3859
+ if not isinstance(per_tool, dict):
3860
+ per_tool = {}
3861
+ # RAW -- `prerequisites_check` below does its OWN tan-cli#760 PATH
3862
+ # confirmation (`available=`) to build the customer-facing `missing`
3863
+ # field; this dict stays unguarded because it is also the source of
3864
+ # `fix_missing`, `--fix`'s (`run_fix`'s) own input -- see
3865
+ # `prerequisites_check`'s docstring and tan-cli#760 review MAJOR 1 for
3866
+ # why those two must NOT be the same value.
3867
+ resolved_install = {k: v for k, v in per_tool.items() if isinstance(v, str)}
3868
+ missing_tools = [tool for tool in required if on_path(tool) is None]
3869
+ # tan-cli#294 finding 3: reintroduces tan-cli#161. Only reachable once the
3870
+ # tool list itself is clean AND a Python actually ran -- mirrors
3871
+ # `check_prerequisites`' own order (`crates/tan-cli/src/commands/
3872
+ # bootstrap/steps.rs:296-298`): presence first, `ensurepip` only after.
3873
+ venv_refusal = None
3874
+ if (
3875
+ sys.platform.startswith("linux")
3876
+ and not missing_tools
3877
+ and python_found is not None
3878
+ and not _posix_venv_capable(python_found[0].split(), executable=python_found[2])
3879
+ ):
3880
+ venv_refusal = posix_venv_unusable()
3881
+ built_prerequisites_check = prerequisites_check(
3882
+ required,
3883
+ missing_tools,
3884
+ resolved_install,
3885
+ source,
3886
+ venv_refusal,
3887
+ # tan-cli#760: confirmed -- not merely present, not GUESSED --
3888
+ # against THIS host's real PATH walk (`on_path`, already used
3889
+ # above for the tool-presence probe itself).
3890
+ available=lambda binary: on_path(binary) is not None,
3891
+ # tan-cli#1066. `facts` is the manifest `_load_manifest` already
3892
+ # parsed and already resolved the SDK root for -- the join happens
3893
+ # in the ONE process that holds both halves, which is the whole
3894
+ # reason this lives in tan and not in the consumer.
3895
+ provenance=facts.get("_artifactProvenance"),
3896
+ )
3897
+
3898
+ return _PrerequisitesEnvironment(
3899
+ bootstrap_manifest_check=bootstrap_manifest_check,
3900
+ prerequisites_check=built_prerequisites_check,
3901
+ facts=facts,
3902
+ manifest_floor=manifest_floor,
3903
+ effective_floor=effective_floor,
3904
+ effective_source=effective_source,
3905
+ python_found=python_found,
3906
+ manifest_is_real=loaded.is_real,
3907
+ host_pythons=tuple(host_pythons),
3908
+ )
3909
+
3910
+
3911
+ def host_environment_checks(sdk_root: str | None, workspace_root: str = ".") -> list[Check]:
3912
+ """The public/internal seam `support-bundle` harvests instead of running
3913
+ `_collect`'s whole build/flash-readiness checklist (tan-cli#441).
3914
+
3915
+ Exactly the five HOST-only checks `_HOST_CHECK_ORDER`
3916
+ (`support_bundle_cmd.py`) names -- `zephyrSdkAvailableForHost`,
3917
+ `longPaths` (Windows only), `homePath`, `bootstrapManifest` (only on a
3918
+ rejected manifest), `hostPrerequisites` -- none of which need a resolved
3919
+ board.yaml, a spawned `west`/JLink/SETOOLS probe, or git provenance. Every
3920
+ one of them is built by the SAME functions `_collect` calls
3921
+ (`zephyr_sdk_host_check`/`long_paths_check`/`home_path_check` directly,
3922
+ `bootstrapManifest`/`hostPrerequisites` via the shared
3923
+ `_resolve_prerequisites_environment`) -- this is a second CALLER, not a
3924
+ second COPY, so a change to any of the five reaches both `tan doctor` and
3925
+ `tan support-bundle` from one place.
3926
+
3927
+ Returned unordered by name (construction order here, not
3928
+ `_HOST_CHECK_ORDER`) -- `support_bundle_cmd._host_checks_from_doctor`
3929
+ already re-indexes by `.name` before use, exactly as it did when it
3930
+ harvested from `_collect`'s much longer list.
3931
+ """
3932
+ workspace_path = west_workspace_dir(
3933
+ workspace_root, Path(sdk_root) if sdk_root is not None else None
3934
+ )
3935
+ host_os, host_arch = _host_os_arch_tags()
3936
+ checks = [zephyr_sdk_host_check(host_os, host_arch)]
3937
+ if os.name == "nt":
3938
+ checks.append(
3939
+ long_paths_check(_long_paths_enabled(), _git_core_longpaths(_resolve_git_executable()))
3940
+ )
3941
+ checks.append(home_path_check(os.environ.get("USERPROFILE" if os.name == "nt" else "HOME")))
3942
+
3943
+ prereq_env = _resolve_prerequisites_environment(sdk_root, workspace_path)
3944
+ if prereq_env.bootstrap_manifest_check is not None:
3945
+ checks.append(prereq_env.bootstrap_manifest_check)
3946
+ checks.append(prereq_env.prerequisites_check)
3947
+ return checks
3948
+
3949
+
3950
+ def _collect(
3951
+ sdk_root: str | None,
3952
+ build: bool = False,
3953
+ board_yaml: str | None = None,
3954
+ project_scope: str | None = None,
3955
+ workspace_root: str = ".",
3956
+ sdk_tier: str | None = None,
3957
+ broken_global_default: str | None = None,
3958
+ on_check: Callable[[Check], None] | None = None,
3959
+ ) -> list[Check]:
3960
+ """Every probe, in report order. `probe`/`on_path`/`_read_text` are the
3961
+ three ways THIS function's own body touches the outside world, and none
3962
+ of them can raise (see the module docstring).
3963
+
3964
+ `on_check`, when given, is a FOURTH path out -- `_collect` does not
3965
+ control what it does, only that it fires, so this function's "nothing
3966
+ here may raise" promise is only as strong as whatever callback the
3967
+ caller supplies. `doctor()`'s real callback (`_print_check`) prints
3968
+ through `_print_stream_lines`, which guards exactly that failure and
3969
+ explains why (see its docstring for the mechanism -- not restated here)
3970
+ rather than letting it unwind back through here and discard every check
3971
+ already collected. A direct test caller that passes a callback of its
3972
+ own is responsible for the same guarantee.
3973
+
3974
+ `on_check` (tan-cli streaming, change 3) fires the MOMENT each check is
3975
+ added, in the exact same order -- `doctor()`'s text branch passes a
3976
+ callback that prints that one check's block immediately, so a slow probe
3977
+ later in this function never leaves an already-answered check unprinted
3978
+ on screen. `None` (every direct test caller of this function, and JSON
3979
+ mode) skips the callback entirely and this returns exactly the batch
3980
+ `list[Check]` it always has.
3981
+
3982
+ `build` (`--build`) is accepted and forwarded from `doctor()` but no
3983
+ longer changes anything here (tan-cli#290): `zephyrWorkspace`, the last
3984
+ check it used to gate, now runs unconditionally alongside `workspace`/
3985
+ `zephyrVersion` -- see `zephyr_workspace_check`'s docstring for why. Kept
3986
+ as a parameter rather than dropped so every existing direct caller (this
3987
+ file's own test suite, and the CLI's own forwarding call) keeps working
3988
+ unchanged; `alp-sdk-vscode`'s `["doctor", "--build"]` call sites keep
3989
+ working too, they just no longer see a different check list.
3990
+
3991
+ `board_yaml`/`project_scope`/`workspace_root` feed the tan-cli#294/#290
3992
+ build-environment preflight (`sdk`/`boardYaml`/`workspace`/
3993
+ `westResolved`/`venvProvenance`/`zephyrVersion`/`zephyrWorkspace`) -- all
3994
+ default so every existing direct caller (this file's own test suite)
3995
+ keeps working unchanged; those checks then simply report against "no
3996
+ board.yaml"/"no workspace resolved from `.`", which is an honest verdict,
3997
+ not a skipped one. `venvProvenance` (tan-cli#292) is the exception that
3998
+ proves the rule: it emits NO check at all (not even against "no board.yaml")
3999
+ when the resolved venv carries no provenance record, which is the common
4000
+ case for a workspace alp-sdk's own `bootstrap.sh` set up.
4001
+
4002
+ `boardYaml`'s severity needs one more fact: whether a project was
4003
+ actually SELECTED (`--project`/`--board-yaml` given), not merely whether
4004
+ the guessed path exists (tan-cli#294 review). `board_yaml` doubles as
4005
+ that signal here: the only way it is non-`None` while its file does NOT
4006
+ exist is an explicitly-given `--board-yaml` (`doctor()`'s own
4007
+ auto-discovery only ever sets it to a path that already `is_file()`), so
4008
+ `board_yaml is not None` is a safe proxy for "explicitly given" exactly
4009
+ where it matters -- the branch where `present` is False.
4010
+
4011
+ `sdk_tier` -- the `SdkSourceTier` `resolve_sdk_root_ladder` answered
4012
+ `sdk_root` with, threaded through so `sdk_check` (tan-cli#301) can name
4013
+ it. Optional/defaulted for the same reason every other parameter here is:
4014
+ every existing direct caller keeps working, reporting `sdk` with no tier
4015
+ parenthetical rather than a guessed one.
4016
+
4017
+ `broken_global_default` (tan-cli#344) -- the raw `sdkPath` a dangling
4018
+ `~/.alp/sdk-default` pointer names, computed once by the caller
4019
+ (`_broken_global_default`) and threaded straight to `sdk_check`. Optional/
4020
+ defaulted like `sdk_tier`; only changes the `sdk` check's remedy text, and
4021
+ only in the branch `sdk_root is None` already reaches.
4022
+ """
4023
+ checks: list[Check] = []
4024
+
4025
+ def _add(check: Check) -> None:
4026
+ checks.append(check)
4027
+ if on_check is not None:
4028
+ on_check(check)
4029
+
4030
+ # tan-cli#294 finding 2: build-environment preflight -- LEADS the report,
4031
+ # mirroring Rust's `prepend_doctor_checks(..., probe_build_preflight(...))`:
4032
+ # "can a build even start" outranks every host-tool probe below.
4033
+ #
4034
+ # tan-cli#301: a checkout discoverable from cwd that a HIGHER tier
4035
+ # outranked is surfaced too, but ONLY the discovery `sdk_check` itself
4036
+ # would have used were nothing above it configured (`discover_sdk_root`,
4037
+ # the WIDE walk `resolve_sdk_root_ladder`'s own tail already falls back
4038
+ # to) -- reusing that exact helper instead of a second, hand-rolled scan
4039
+ # is what keeps this a report-only addition: it can only ever name a
4040
+ # candidate the ladder itself already knows how to reach, never invent
4041
+ # one of its own. Skipped when the winning tier already IS discovery (or
4042
+ # nothing): there is nothing "unselected" left to name.
4043
+ unselected_candidate: str | None = None
4044
+ if sdk_root is not None and sdk_tier not in (None, "discovery", "none"):
4045
+ candidate = discover_sdk_root(Path(workspace_root))
4046
+ # `normcase` BOTH sides. `_abs_posix` is `abspath` + slash-swap and
4047
+ # deliberately does not resolve, so on Windows the SAME directory
4048
+ # spelled with different case -- a `~/.alp/sdk-default` written from a
4049
+ # differently-cased `tan sdk switch`, or a differing drive-letter case
4050
+ # -- compared unequal and the report told the user to select the SDK
4051
+ # that was already selected:
4052
+ # alp-sdk at ...\ws\ALP-SDK (globalDefault; a checkout at
4053
+ # ...\ws\alp-sdk was not selected -- pass --sdk-root ... to use it)
4054
+ # A report that lies is the defect class #301 exists to close, so it
4055
+ # must not be reintroduced by the fix for it. No-op on POSIX.
4056
+ if candidate is not None and os.path.normcase(
4057
+ _abs_posix(str(candidate))
4058
+ ) != os.path.normcase(_abs_posix(sdk_root)):
4059
+ unselected_candidate = str(candidate)
4060
+ # tan-cli#407: the #301 block above deliberately says nothing when the
4061
+ # winning tier already IS `discovery` -- and that is precisely the case
4062
+ # where the second checkout is not merely "unselected" but ACTIVELY in
4063
+ # use, by the four commands that take the wide ladder. Same report-only
4064
+ # discipline as #301: the candidate can only ever be one
4065
+ # `resolve_sdk_root_wide` itself would reach, never a scan of its own.
4066
+ divergent_candidate: str | None = None
4067
+ if sdk_root is not None and sdk_tier == "discovery":
4068
+ wide = resolve_sdk_root_wide(None, Path(workspace_root)).path
4069
+ # `normcase` both sides, for the reason spelled out in the #301 block
4070
+ # above: `_abs_posix` does not resolve, so on Windows one directory
4071
+ # under two spellings would be reported as two checkouts.
4072
+ if wide is not None and os.path.normcase(_abs_posix(str(wide))) != os.path.normcase(
4073
+ _abs_posix(sdk_root)
4074
+ ):
4075
+ divergent_candidate = str(wide)
4076
+ # tan-cli#727. Guarded at THIS call site, not inside
4077
+ # `resolve_sdk_root_ladder` -- the same placement `build_cmd`,
4078
+ # `run_cmd`, `validate_cmd`, `clean_cmd.sdk_root_resolves` and
4079
+ # `flash_cmd._resolve_sdk` each chose, because every other caller depends
4080
+ # on the ladder handing the flag back unvalidated.
4081
+ dangling_flag_root = (
4082
+ sdk_root if sdk_tier == "sdkRootFlag" and not is_sdk_root(sdk_root) else None
4083
+ )
4084
+ _add(
4085
+ sdk_check(
4086
+ sdk_root,
4087
+ project_scope,
4088
+ sdk_tier,
4089
+ unselected_candidate,
4090
+ broken_global_default,
4091
+ divergent_candidate,
4092
+ dangling_flag_root,
4093
+ )
4094
+ )
4095
+ project_selected = bool(project_scope and project_scope.strip()) or board_yaml is not None
4096
+ _add(
4097
+ board_yaml_preflight_check(
4098
+ board_yaml is not None and Path(board_yaml).is_file(), project_selected
4099
+ )
4100
+ )
4101
+ # Immediately after `boardYaml`, and reading the SAME two facts `sdk` and
4102
+ # `boardYaml` just reported on -- the selection is a fact ABOUT that file,
4103
+ # resolved against THAT checkout, so a report where the two disagree would
4104
+ # be incoherent. Emits nothing at all for a project that selects no
4105
+ # curated libraries (see `libraries_check`).
4106
+ libraries = libraries_check(
4107
+ inspect_selection(
4108
+ Path(board_yaml) if board_yaml is not None else None,
4109
+ Path(sdk_root) if sdk_root is not None else None,
4110
+ )
4111
+ )
4112
+ if libraries is not None:
4113
+ _add(libraries)
4114
+ workspace_path = west_workspace_dir(
4115
+ workspace_root, Path(sdk_root) if sdk_root is not None else None
4116
+ )
4117
+ _add(
4118
+ workspace_preflight_check(
4119
+ str(workspace_path) if workspace_path is not None else None,
4120
+ start=workspace_root,
4121
+ sdk_root=sdk_root,
4122
+ zephyr_base=os.environ.get("ZEPHYR_BASE"),
4123
+ )
4124
+ )
4125
+
4126
+ # tan-cli#290: `westResolved`, right after `workspace` -- the same order
4127
+ # Rust's `build_preflight_checks` uses (`sdk`, `boardYaml`, `workspace`,
4128
+ # `westResolved`, `zephyrVersion`). The resolved binary is the SAME one
4129
+ # `tan build` would spawn (`tan.core.venv.west_program`): an absolute
4130
+ # venv path is trusted directly (`find_workspace_venv` already confirmed
4131
+ # it exists), a bare `"west"` fallback is re-checked against PATH, never
4132
+ # the reverse -- so a `westResolved` version can never be attributed to a
4133
+ # different binary than the one that answered it (tan-cli#123's exact
4134
+ # bug, reintroduced by the port and closed here).
4135
+ resolved_west = west_program(workspace_root, sdk_root)
4136
+ west_resolved_exe = (
4137
+ resolved_west if os.path.isabs(resolved_west) else on_path(resolved_west)
4138
+ )
4139
+ # tan-cli#488 defect 1: `probe_status`, not `probe` -- `west_resolved_ran`
4140
+ # tells `west_resolved_check` whether `--version` actually ran to a
4141
+ # successful exit, separately from whether its output parsed. `probe()`
4142
+ # alone collapses "could not be spawned" (a `west` launcher whose file
4143
+ # survives a relocated/renamed workspace but whose interpreter does not)
4144
+ # and "spawned, exited zero, printed something unparseable" into the same
4145
+ # `None` -- `pass` is only correct for the second.
4146
+ west_resolved_ran, west_resolved_stdout = (
4147
+ probe_status([west_resolved_exe, "--version"])
4148
+ if west_resolved_exe is not None
4149
+ else (False, None)
4150
+ )
4151
+ west_resolved_version = _parse_two(west_resolved_stdout or "")
4152
+ _add(west_resolved_check(west_resolved_exe, west_resolved_version, west_resolved_ran))
4153
+
4154
+ # tan-cli#292: `venvProvenance`, right beside `westResolved` -- it is a
4155
+ # verdict on the SAME resolved venv (`find_workspace_venv`, the search
4156
+ # `west_program` itself resolves `west` through), just reading its
4157
+ # tan-written provenance record instead of probing the binary.
4158
+ venv_path = find_workspace_venv(workspace_root, sdk_root)
4159
+ venv_record: WorkspaceSdkRecord | None = None
4160
+ if venv_path is not None:
4161
+ record_text = _read_text(venv_path.parent / ".west" / "tan-workspace-sdk")
4162
+ if record_text is not None:
4163
+ venv_record = parse_workspace_sdk_record(record_text)
4164
+ provenance_check = venv_provenance_check(venv_record, sdk_root)
4165
+ if provenance_check is not None:
4166
+ _add(provenance_check)
4167
+
4168
+ if workspace_path is not None:
4169
+ workspace_version = None
4170
+ version_body = _read_text(workspace_path / "zephyr" / "VERSION")
4171
+ if version_body is not None:
4172
+ workspace_version = parse_zephyr_version_file(version_body)
4173
+ sdk_pin_for_workspace = None
4174
+ if sdk_root is not None:
4175
+ west_yml_body = _read_text(Path(sdk_root) / "west.yml")
4176
+ if west_yml_body is not None:
4177
+ sdk_pin_for_workspace = parse_west_zephyr_pin(west_yml_body)
4178
+ zephyr_version_check = zephyr_version_preflight_check(
4179
+ workspace_version, sdk_pin_for_workspace
4180
+ )
4181
+ if zephyr_version_check is not None:
4182
+ _add(zephyr_version_check)
4183
+ # tan-cli#290: unconditional now, sourced from these SAME resolved
4184
+ # facts -- see `zephyr_workspace_check`'s docstring for why it still
4185
+ # earns its own check beside `zephyrVersion` rather than being
4186
+ # dropped as a duplicate.
4187
+ _add(zephyr_workspace_check(str(workspace_path), workspace_version))
4188
+ # tan-cli#1376: read-only; never raises, never fails an offline doctor.
4189
+ _add(
4190
+ workspace_patches_check(
4191
+ check_workspace_patches(workspace_path, sdk_root, timeout=30), str(workspace_path)
4192
+ )
4193
+ )
4194
+ zephyr_base_info = zephyr_base_check(os.environ.get("ZEPHYR_BASE"), str(workspace_path))
4195
+ if zephyr_base_info is not None:
4196
+ _add(zephyr_base_info)
4197
+
4198
+ # tan-cli#294 finding 1: host-environment checks -- also unconditional
4199
+ # HOST facts (no board.yaml/workspace/SDK needed). See their docstrings.
4200
+ host_os, host_arch = _host_os_arch_tags()
4201
+ _add(zephyr_sdk_host_check(host_os, host_arch))
4202
+ if os.name == "nt":
4203
+ _add(
4204
+ long_paths_check(_long_paths_enabled(), _git_core_longpaths(_resolve_git_executable()))
4205
+ )
4206
+ _add(home_path_check(os.environ.get("USERPROFILE" if os.name == "nt" else "HOME")))
4207
+ # `tanInstall`: is this running tan behind the source it was installed from?
4208
+ _add(_tan_install_check())
4209
+
4210
+ # tan-cli#441: `bootstrapManifest` + `hostPrerequisites` (and the manifest
4211
+ # load + Python probe `hostPython`/`pythonFloor`/`west` below also need)
4212
+ # are built by the ONE shared `_resolve_prerequisites_environment` --
4213
+ # `host_environment_checks` calls the SAME function for support-bundle's
4214
+ # seam, so the two callers can never build a diverging verdict.
4215
+ #
4216
+ # tan-cli#980 review nit 3, decided rather than restored: because this
4217
+ # call now builds `hostPrerequisites` (PATH walk over `required`, plus
4218
+ # the POSIX `_posix_venv_capable` spawn) EAGERLY as part of the same
4219
+ # shared unit, `bootstrapManifest`/`hostPython`/`pythonFloor` below are
4220
+ # `_add`ed -- and so, in `tan doctor` TEXT mode, printed by `on_check` --
4221
+ # AFTER that PATH walk and spawn finish, where pre-#441 `_collect` built
4222
+ # and `_add`ed each of the three the moment it had the data, before ever
4223
+ # touching PATH or spawning `_posix_venv_capable`. The returned list
4224
+ # order and the JSON envelope are byte-identical either way (verified in
4225
+ # the PR's own equivalence pass) -- only the incremental print TIMING
4226
+ # `on_check` exists for shifts by the wall-clock cost of two spawns this
4227
+ # single caller already pays for regardless of which check the delay is
4228
+ # attributed to. Restoring the old interleaving would mean splitting
4229
+ # `_resolve_prerequisites_environment` back into a pre-PATH-walk half and
4230
+ # a post-PATH-walk half so `_collect` could `_add` between them -- which
4231
+ # reopens the exact two-copies-of-one-decision risk this helper exists to
4232
+ # close, for a few hundred milliseconds of `tan doctor` console timing.
4233
+ # Accepted as-is.
4234
+ prereq_env = _resolve_prerequisites_environment(sdk_root, workspace_path)
4235
+ if prereq_env.bootstrap_manifest_check is not None:
4236
+ _add(prereq_env.bootstrap_manifest_check)
4237
+ facts = prereq_env.facts
4238
+ effective_floor = prereq_env.effective_floor
4239
+ effective_source = prereq_env.effective_source
4240
+ python_found = prereq_env.python_found
4241
+
4242
+ _add(
4243
+ host_python_check(
4244
+ python_found[:2] if python_found else None,
4245
+ effective_floor,
4246
+ effective_source,
4247
+ find_workspace_venv(workspace_root, sdk_root) is not None,
4248
+ list(prereq_env.host_pythons),
4249
+ )
4250
+ )
4251
+ skew = python_floor_skew_check(
4252
+ prereq_env.manifest_floor,
4253
+ effective_floor,
4254
+ effective_source,
4255
+ manifest_is_real=prereq_env.manifest_is_real,
4256
+ )
4257
+ if skew is not None:
4258
+ _add(skew)
4259
+
4260
+ _add(prereq_env.prerequisites_check)
4261
+
4262
+ west_exe = on_path("west")
4263
+ # tan-cli#488 defect 5: probe `west_exe` -- the PATH-RESOLVED absolute
4264
+ # path `on_path` just returned -- never a second, bare-name spawn.
4265
+ # `on_path` deliberately excludes the cwd for exactly this reason (its own
4266
+ # docstring: a project checked out with its own `west.exe` at its root
4267
+ # must not be reported as this host's tooling); a bare `probe(["west",
4268
+ # ...])` re-opens that gap outright -- CreateProcess searches the cwd
4269
+ # before PATH on Windows, and an empty PATH element (a leading/trailing
4270
+ # `:`, common in a hand-edited profile) does the same on POSIX -- so the
4271
+ # reported version could come from a DIFFERENT binary than the one this
4272
+ # check names. Every other probe in `_collect` already spawns the
4273
+ # resolved path (`west_resolved_exe` above, `jlink_exe` below); this is
4274
+ # the one that did not.
4275
+ west_version = _parse_two(probe([west_exe, "--version"]) or "") if west_exe else None
4276
+ # tan-cli#299 second half: feed `west_check` the SAME resolved venv path
4277
+ # `westResolved` above already computed (`resolved_west`) -- never a
4278
+ # second, independent probe -- so "absent from bare PATH, present in the
4279
+ # resolved venv" (the default post-bootstrap state) reports `pass`
4280
+ # instead of a permanent warn. Only passed when it is a real venv
4281
+ # binary (an absolute path); `west_program`'s bare-`"west"` fallback
4282
+ # carries no information `west_exe` above does not already have.
4283
+ #
4284
+ # tan-cli#488 defect 3: `west_resolved_ran` (computed above, the same
4285
+ # `probe_status` result `westResolved` itself was fixed to consult for
4286
+ # defect 1) also passed through -- so a resolved-but-unspawnable west
4287
+ # cannot report `pass` here while `westResolved` reports `fail` for the
4288
+ # identical binary.
4289
+ _add(
4290
+ west_check(
4291
+ west_exe,
4292
+ west_version,
4293
+ _parse_two(str(facts.get("_pipSpec") or "")),
4294
+ resolved_west if os.path.isabs(resolved_west) else None,
4295
+ west_resolved_ran,
4296
+ )
4297
+ )
4298
+
4299
+ # Unconditional -- not gated on `build` or a resolved board.yaml/SDK. See
4300
+ # `zephyr_sdk_check`'s docstring (tan-cli#286).
4301
+ zephyr_sdk_ok = _zephyr_sdk_detected()
4302
+ _add(zephyr_sdk_check(zephyr_sdk_ok, os.environ.get("ZEPHYR_SDK_INSTALL_DIR")))
4303
+ # issue #474 (ADR 0021 Lane 1 P1): stamp-vs-pin, narrower than `zephyrSdk`
4304
+ # above -- see `toolchain_check`'s own docstring for the distinction.
4305
+ _add(toolchain_check(sdk_root))
4306
+ # tan-cli#1192: beside the two toolchain checks because it reads the SAME
4307
+ # resolved SDK install (`_zephyr_sdk_detected_root`) they do -- but it
4308
+ # answers a question neither does: whether Zephyr's diagnostics-only `dtc`
4309
+ # pass will run at all now that `tan bootstrap` acquires the SDK with
4310
+ # `--no-hosttools` (tan-cli#1178) and so ships no `dtc`. NOT added to
4311
+ # `host_environment_checks` below: `tan support-bundle` reports only the
4312
+ # five checks `_HOST_CHECK_ORDER` names, and this is not one of them --
4313
+ # see `devicetree_lint_check`'s docstring for why that is deliberate.
4314
+ _add(devicetree_lint_check(_resolve_dtc()))
4315
+ # tan-cli#736: unconditional on Windows. This used to ride the
4316
+ # `zephyrSdk` Fail (`and not zephyr_sdk_ok`), which silently excused the
4317
+ # host that has an SDK and no 7-Zip -- exactly the host whose next
4318
+ # `west sdk install` dies with `Zephyr SDK setup requires '7z'`. Still a
4319
+ # `warn`, not a `fail`: see `seven_zip_check`'s docstring and
4320
+ # tan-cli#204 for why the SEVERITY is right and only the GATE was wrong.
4321
+ if os.name == "nt":
4322
+ # tan-cli#1523: same predicate `tan bootstrap`'s preflight uses, so
4323
+ # doctor cannot pass a host (unar-only, or 7z.exe only in the cwd)
4324
+ # that bootstrap refuses.
4325
+ from tan.commands.bootstrap_toolchain_fetch import _find_seven_zip
4326
+
4327
+ _add(seven_zip_check(_find_seven_zip() is not None))
4328
+
4329
+ # tan-cli#1323: the precedence `tan flash` applies (`resolve_setools_dir`),
4330
+ # minus the per-run `--setools-dir` flag doctor cannot see: $SETOOLS_DIR,
4331
+ # then the built manifest's flash_args.setools_dir.
4332
+ flash_info = project_flash(board_yaml)
4333
+ setools_dir = os.environ.get("SETOOLS_DIR") or (
4334
+ flash_info.setools_dir if flash_info is not None else None
4335
+ )
4336
+ setools_source = (
4337
+ "$SETOOLS_DIR" if os.environ.get("SETOOLS_DIR") else "the manifest's flash_args.setools_dir"
4338
+ )
4339
+ _add(
4340
+ setools_check(
4341
+ setools_dir,
4342
+ os.environ.get("SE_UART"),
4343
+ sys.platform.startswith("linux"),
4344
+ flash_info.methods if flash_info is not None else None,
4345
+ jlink_available(
4346
+ str(Path(board_yaml).parent) if board_yaml is not None else workspace_root,
4347
+ sdk_root,
4348
+ ),
4349
+ setools_source,
4350
+ )
4351
+ )
4352
+
4353
+ jlink_name, jlink_exe = next(
4354
+ (
4355
+ (name, found)
4356
+ for name in ("JLinkExe", "JLink", "JLinkGDBServerCL")
4357
+ if (found := on_path(name))
4358
+ ),
4359
+ (None, None),
4360
+ )
4361
+ # JLinkExe has no `-?` flag (`Unknown command line option -?.`); the
4362
+ # version banner it prints unprompted at the top of every real invocation
4363
+ # is what `jlink_banner` reads instead. See its docstring. NOT spawned
4364
+ # against `JLinkGDBServerCL` (the third fallback name): that binary never
4365
+ # reads stdin and never quits on `jlink_banner`'s `exit\n`, so it would
4366
+ # hold a GDB port for the full `PROBE_TIMEOUT_S` on every `tan doctor`
4367
+ # run on a host where only the GDB server is on PATH. `jlink_check`
4368
+ # already reports "found but version could not be read" for a `None`
4369
+ # version, which is the correct outcome here too.
4370
+ jlink_version = (
4371
+ _parse_two(jlink_banner(jlink_exe) or "")
4372
+ if jlink_exe and jlink_name != "JLinkGDBServerCL"
4373
+ else None
4374
+ )
4375
+ resolved_device, device_source = jlink_flash_device(sdk_root)
4376
+ _add(jlink_check(jlink_exe, jlink_version, resolved_device, device_source))
4377
+
4378
+ # tan-cli#294 finding 5: LAST, mirroring `assemble_doctor_report`'s own
4379
+ # placement -- traces a report back to the SDK checkout that produced it.
4380
+ # tan-cli#727: skipped for a `--sdk-root` the marker check rejected.
4381
+ # `sdk_provenance_check` finds no git checkout and no
4382
+ # `metadata/sdk_version.yaml` under a path that is not there, and its
4383
+ # else-branch renders that absence as `pass | alp-sdk at <path> (no git
4384
+ # checkout / metadata/sdk_version.yaml)` -- a green line asserting an
4385
+ # alp-sdk at a directory that does not exist.
4386
+ if sdk_root is not None and dangling_flag_root is None:
4387
+ _add(sdk_provenance_check(sdk_root))
4388
+
4389
+ return checks
4390
+
4391
+
4392
+ def _generated_at() -> str:
4393
+ """`SOURCE_DATE_EPOCH` when set, so a captured envelope is reproducible --
4394
+ `tan.core.timestamp`, which NEVER raises.
4395
+
4396
+ An out-of-range epoch (the MILLISECONDS case) used to throw from here, and
4397
+ the caller's own try/except then reported `doctor.internal-failure`: a
4398
+ fabricated "tan is broken" verdict on a host that was diagnosed fine.
4399
+ """
4400
+ return generated_at_iso()
4401
+
4402
+
4403
+ def _print_stream_lines(lines: Iterable[str], label: str) -> None:
4404
+ """The ONE guarded stderr-print path `doctor()`'s two streaming call
4405
+ sites both go through -- once per finished check (`_print_check`) and
4406
+ once more for the footer (summary / failed-checks / extra-issue lines)
4407
+ -- neither prints via a bare `print()` loop of its own any more.
4408
+
4409
+ Measured, not inferred (this repo's own oracle rule -- established by
4410
+ RUNNING, never by reasoning about it): `tan.cli.main` calls
4411
+ `_reconfigure_stdio()` before any command runs, which puts `sys.stderr`
4412
+ in utf-8/`errors="strict"` -- a legacy-codepage stream (`cp1252`) never
4413
+ reaches this function on the real CLI path, and `cp1252` can encode `…`
4414
+ (U+2026) anyway (`"\\u2026".encode("cp1252")` == `b"\\x85"`), so that was
4415
+ never the actual risk. What DOES raise `UnicodeEncodeError` on a strict
4416
+ utf-8 stream is a lone surrogate (e.g. `\\udcff`) -- a filesystem path
4417
+ that reached Python via `surrogateescape` (`on_path`'s `shutil.which`
4418
+ result, resolved off `PATH`) and that a check's `detail`/`fix` text goes
4419
+ on to print verbatim; utf-8 cannot encode a lone surrogate no matter what
4420
+ decoded it. `OSError` (its subclass `BrokenPipeError` in particular)
4421
+ covers a closed PIPE (`tan doctor | head`); `ValueError` covers a stream
4422
+ closed OUTRIGHT (`I/O operation on closed file` -- the object itself is
4423
+ gone, not merely refusing writes at the far end) -- included
4424
+ deliberately, because the one thing this guard exists to prevent,
4425
+ discarding an already-finished diagnosis over how the stream died, does
4426
+ not care which of the three killed it, and the `try` body here is
4427
+ nothing but `print()` calls to a fixed stream, so a bare `ValueError`
4428
+ catch has no wider blast radius to worry about.
4429
+
4430
+ Unguarded, any of the three propagates out of `_collect` (as `on_check`)
4431
+ into `doctor()`'s own `except Exception`, discarding every check this
4432
+ run already completed as a fabricated `doctor.internal-failure`; from
4433
+ the footer call site (never inside that `try`) it would instead escape
4434
+ `doctor()` outright as a raw, unhandled traceback. Caught here instead,
4435
+ and never silently: a second, INDEPENDENT `try` wraps the failure note
4436
+ itself, since it prints through the SAME `sys.stderr` that just failed.
4437
+ Nothing left to do if even that fails -- the diagnosis behind it already
4438
+ made it back to `doctor()` (or, for the footer, was already fully
4439
+ rendered) regardless.
4440
+ """
4441
+ try:
4442
+ for line in lines:
4443
+ print(line, file=sys.stderr)
4444
+ except (UnicodeError, OSError, ValueError) as err:
4445
+ try:
4446
+ print(
4447
+ f"doctor: could not print the '{label}' block "
4448
+ f"({type(err).__name__}: {err}).",
4449
+ file=sys.stderr,
4450
+ )
4451
+ except (UnicodeError, OSError, ValueError):
4452
+ pass
4453
+
4454
+
4455
+ def doctor(
4456
+ project: str = typer.Option(
4457
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
4458
+ ),
4459
+ sdk_root: str = typer.Option(
4460
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
4461
+ ),
4462
+ board_yaml: str = typer.Option(
4463
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
4464
+ ),
4465
+ build: bool = typer.Option(
4466
+ False,
4467
+ "--build",
4468
+ help=inert_help(
4469
+ "Accepted for compatibility: zephyrWorkspace, the check this used "
4470
+ "to gate, now runs unconditionally, so this flag no longer changes "
4471
+ "the check list.",
4472
+ COMPATIBILITY,
4473
+ "tan-cli#290",
4474
+ ),
4475
+ ),
4476
+ fix: bool = typer.Option(
4477
+ False,
4478
+ "--fix",
4479
+ help="Run the manifest's own install command (ADR 0021) for any "
4480
+ "hostPrerequisites tool this host is missing. A command needing no "
4481
+ "elevation runs for any caller; a `sudo`-prefixed one runs too, but "
4482
+ "only when this process is already root, with the literal `sudo` "
4483
+ "word stripped first -- tan itself never spawns the `sudo` program. "
4484
+ "A non-root caller instead gets that command printed to run by hand. "
4485
+ "Only in an interactive, non-CI, text-mode run (a real terminal on "
4486
+ "both stdin and stderr; --non-interactive/--ci/--format json all "
4487
+ "disable it): a repair a human did not watch happen is not consent.",
4488
+ ),
4489
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
4490
+ non_interactive: bool = typer.Option(
4491
+ False,
4492
+ "--non-interactive",
4493
+ help="Never prompt, and never run --fix's repairs -- see --fix.",
4494
+ ),
4495
+ ci: bool = typer.Option(
4496
+ False, "--ci", help="CI mode: implies --non-interactive and disables --fix."
4497
+ ),
4498
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colour."),
4499
+ ) -> None:
4500
+ """Diagnose whether this host can build and flash."""
4501
+ json_mode = output_format == "json"
4502
+
4503
+ # Snapshot the RAW `--project` value before `project` is reassigned below
4504
+ # to the envelope's `Project` object -- `sdk_check`'s scoped-switch hint
4505
+ # (tan-cli#294 finding 2 / #101) needs the string, not the envelope block.
4506
+ project_scope = project
4507
+
4508
+ # tan-cli#488 defect 8: safe defaults for every name the exception
4509
+ # handler below and the final `emit()` read, so a raise from ANYWHERE
4510
+ # in this prologue -- not only inside `_collect` -- still produces the
4511
+ # `doctor.internal-failure` envelope the guard below promises, instead
4512
+ # of a raw traceback with empty stdout. `Path.cwd()` a few lines below
4513
+ # is the concrete case: a working directory deleted out from under the
4514
+ # process (a cleanup script racing a `tan doctor` run in the same
4515
+ # shell) throws `FileNotFoundError` -- previously from OUTSIDE the
4516
+ # `try`/`except`, before `project`/`sdk`/`sdk_tier` had a real value,
4517
+ # so it unwound through typer's own traceback renderer instead of this
4518
+ # command's error contract. Every value here is overwritten the moment
4519
+ # the try body below reaches its own assignment.
4520
+ project_envelope: Project = Project(root=None, board_yaml=None)
4521
+ sdk: SdkInfo | None = None
4522
+ sdk_tier = "none"
4523
+ sdk_broken_pin: str | None = None
4524
+ sdk_foreign_default: str | None = None
4525
+ try:
4526
+ # `util::cli_workspace_root`: `--project` joined onto the cwd, and
4527
+ # everything below (board.yaml discovery, SDK discovery, the reported
4528
+ # `project.root`) anchors on THAT -- see `build_cmd.build` for the same
4529
+ # pattern and why an unanchored `--project` builds the wrong project.
4530
+ cwd = Path.cwd()
4531
+ workspace_root = cwd if project is None else Path(os.path.join(str(cwd), project))
4532
+
4533
+ # Anchor an EXPLICIT `--board-yaml` on `workspace_root`, not the real cwd,
4534
+ # BEFORE the discovery branch below -- same pattern as `build_cmd.build`
4535
+ # and `crates/tan-core/src/project.rs:198-208`'s `resolve_board_yaml_path`.
4536
+ # Left unanchored, a relative `--board-yaml` under `--project app` reports
4537
+ # (and would build/flash) the board.yaml sitting in the real cwd instead
4538
+ # of the one inside `app`.
4539
+ if board_yaml is not None and not os.path.isabs(board_yaml):
4540
+ board_yaml = os.path.join(str(workspace_root), board_yaml)
4541
+ if board_yaml is None and (workspace_root / "board.yaml").is_file():
4542
+ board_yaml = str(workspace_root / "board.yaml")
4543
+ # `--sdk-root` > `.alp/sdk-path` project pin > machine-global default >
4544
+ # the positional walk (`resolve_sdk_root_ladder`) -- no `ALP_SDK_ROOT`
4545
+ # tier (tried and reverted -- see `resolve_sdk_root_ladder`'s own
4546
+ # docstring). Previously this skipped straight from `--sdk-root` to the
4547
+ # positional walk, silently ignoring `tan init`'s own pointer in the same
4548
+ # directory.
4549
+ sdk_resolution = resolve_sdk_root_ladder(sdk_root, workspace_root)
4550
+ resolved_sdk_root = sdk_resolution.path
4551
+ sdk_tier = sdk_resolution.tier
4552
+ sdk_broken_pin = sdk_resolution.broken_project_pin
4553
+ sdk_foreign_default = sdk_resolution.foreign_global_default_for
4554
+ sdk_root = str(resolved_sdk_root) if resolved_sdk_root is not None else None
4555
+ sdk = SdkInfo.from_resolution(sdk_root, sdk_resolution) if sdk_root is not None else None
4556
+ # tan-cli#344: a dangling `~/.alp/sdk-default` is a distinct fact from
4557
+ # "nothing configured" -- computed unconditionally (one small file read)
4558
+ # so `sdk_check` can name it in the one branch (`sdk_root is None`) where
4559
+ # the two used to print the identical sentence.
4560
+ broken_global_default = _broken_global_default()
4561
+ # Forward slashes -- the established envelope contract on this seam
4562
+ # (`build_cmd.build`, `flash_cmd._resolve_project`), not the native
4563
+ # separators `str(Path(...))` would emit on Windows.
4564
+ #
4565
+ # tan-cli#236: `boardYaml` reported only when the file really exists. An
4566
+ # explicit `--board-yaml` skips the `is_file()` discovery guard above, so
4567
+ # without this it could still name a path nothing sits at.
4568
+ project_envelope = Project.resolved(
4569
+ _abs_posix(str(workspace_root)),
4570
+ _abs_posix(board_yaml) if board_yaml is not None else None,
4571
+ )
4572
+
4573
+ # Text mode streams each check the MOMENT it completes (tan-cli doctor
4574
+ # v2, change 3): `_collect` runs up to 15 subprocess probes at
4575
+ # `PROBE_TIMEOUT_S` each, and the old code printed nothing until every
4576
+ # one of them had answered -- on a host where one wedges, a blank
4577
+ # terminal names nothing. Width floored at `TEXT_WRAP_MIN_WIDTH` -- a
4578
+ # 20-column terminal must not fall to one word per line -- and measured
4579
+ # through `tan.env.terminal_width`, the one place that reads a
4580
+ # terminal's columns, sharing the 100-column fallback with
4581
+ # `tan.env.wrap_width`. tan-cli#564: this used to be a bare
4582
+ # `shutil.get_terminal_size(fallback=(100, 24))`, which measures
4583
+ # `sys.__stdout__` -- but this report is printed to STDERR, so
4584
+ # `tan doctor > report.txt` from a 70-column terminal took the 100 and
4585
+ # put 94-, 95- and 105-column lines on a 70-column screen.
4586
+ # `width`/`color` are resolved unconditionally, even under
4587
+ # `--format json` where neither is ever read: both calls are cheap and
4588
+ # emit nothing, so gating them behind `if stream` bought nothing but a
4589
+ # second, held-together-by-an-11-line-comment invariant (`_print_check`
4590
+ # closes over both) for no benefit.
4591
+ #
4592
+ # UNCONDITIONAL on stderr being a terminal at all (unlike
4593
+ # `tan.env.wrap_width`, which `explain`/`sdk current` use instead): a
4594
+ # piped `tan doctor` still wraps to `terminal_width`'s own fallback
4595
+ # today. That is a real inconsistency with the new seam, left exactly as
4596
+ # it shipped in PR #480 rather than silently fixed here -- see that PR
4597
+ # for the call to change it.
4598
+ stream = not json_mode
4599
+ width = max(terminal_width(100), TEXT_WRAP_MIN_WIDTH)
4600
+ color = use_color(no_color, ci)
4601
+
4602
+ def _print_check(check: Check) -> None:
4603
+ """`on_check`'s real, streaming callback: render one check's block
4604
+ and print it through `_print_stream_lines` -- the FOURTH way
4605
+ `_collect` reaches the outside world (see its docstring), and the
4606
+ only one of the four that can raise. See `_print_stream_lines`'s own
4607
+ docstring for the guard and why it is shaped the way it is; not
4608
+ restated here.
4609
+ """
4610
+ _print_stream_lines(render_check_lines(check.as_dict(), width, color), check.name)
4611
+
4612
+ checks = _collect(
4613
+ sdk_root,
4614
+ build=build,
4615
+ board_yaml=board_yaml,
4616
+ project_scope=project_scope,
4617
+ workspace_root=str(workspace_root),
4618
+ sdk_tier=sdk_tier,
4619
+ broken_global_default=broken_global_default,
4620
+ on_check=_print_check if stream else None,
4621
+ )
4622
+ # tan-cli#91 / ADR 0021: `--fix` only ever RUNS anything when a human
4623
+ # is demonstrably present. `doctor` otherwise only REPORTS; this flag
4624
+ # turns it into a machine-global, network-fetching installer, so the
4625
+ # consent gate is the feature, not decoration around it.
4626
+ #
4627
+ # Delegated to [`tan.core.consent.can_prompt`] rather than spelled out
4628
+ # inline, because spelling it out inline is exactly how this went
4629
+ # wrong: the hand-written form tested only the three FLAGS
4630
+ # (`!non_interactive && !ci && !is_json`) and omitted the two
4631
+ # `isatty()` calls, so a CI runner that redirected its output but did
4632
+ # not happen to pass `--ci` got unattended host mutation -- measured
4633
+ # under fully captured pipes, four real `winget install` runs with
4634
+ # nobody watching. The oracle's own `--non-interactive` help states
4635
+ # the missing half ("the same rule applies unasked when stdin or
4636
+ # stderr is not a terminal -- piped, redirected, or a CI runner").
4637
+ # See that module for why BOTH handles matter, and why `stdout`
4638
+ # deliberately does not.
4639
+ fix_allowed = fix and can_prompt(non_interactive=non_interactive, ci=ci, json_mode=json_mode)
4640
+ if fix_allowed:
4641
+ # `fix_missing`, NEVER `missing`: tan-cli#760 review MAJOR 1.
4642
+ # `missing` is the CONFIRMED, customer-facing field (`data.
4643
+ # missingPrerequisites`) and nulls a command whose own installer
4644
+ # this host cannot confirm; `run_fix` needs the RAW commands to
4645
+ # do its OWN on_path resolution and keep reporting tan-cli#360's
4646
+ # `doctor.fix-installer-not-found` diagnosis on the hosts #360
4647
+ # exists for, instead of a silent no-op.
4648
+ missing_for_fix = next(
4649
+ (c.fix_missing for c in checks if c.name == "hostPrerequisites"), None
4650
+ )
4651
+ if missing_for_fix:
4652
+ # These checks stream too, same as every check `_collect`
4653
+ # produced above -- `run_fix` takes the SAME `on_check`
4654
+ # `_collect` does, gated on `stream` directly (never on
4655
+ # "`fix_allowed` already implies `not json_mode`": that
4656
+ # inference only holds for the REAL `can_prompt`, and a test
4657
+ # double stubbing `can_prompt` can make `fix_allowed` true
4658
+ # under `--format json`, where printing a check block to
4659
+ # stderr would be wrong regardless of `width`/`color` being
4660
+ # bound -- both are resolved unconditionally now, see above).
4661
+ fix_checks = run_fix(missing_for_fix, on_check=_print_check if stream else None)
4662
+ checks = [*checks, *fix_checks]
4663
+ exit_code = exit_code_for(checks)
4664
+ issues = checks_to_issues(checks)
4665
+ # tan-cli#91 P1: `--fix` requested and consent refused used to be a
4666
+ # SILENT no-op, byte-for-byte identical to plain `tan doctor` --
4667
+ # measured against the oracle (`doctor --fix --format json`, which the
4668
+ # oracle instead refuses to parse outright). SAY SO instead: name
4669
+ # every condition of `can_prompt`'s that actually tripped.
4670
+ if fix and not fix_allowed:
4671
+ issues = [
4672
+ *issues,
4673
+ fix_suppressed_issue(non_interactive=non_interactive, ci=ci, json_mode=json_mode),
4674
+ ]
4675
+ # tan-cli#294 finding 4 (#203/#210, alp-sdk-vscode#347, ADR 0021 P0a):
4676
+ # `hostPrerequisites` is the only check that ever carries a
4677
+ # `{tool, command}` pair, so it is the only place this reads from --
4678
+ # mirrors `apply_prerequisite_check`'s report-level field. `alp-sdk-
4679
+ # vscode`'s `runDependencyAction` sends `missingPrerequisites[].command`
4680
+ # to a terminal; without this key that one-click affordance silently
4681
+ # disappears on the extension side (the extension itself does not
4682
+ # crash on absence -- it feature-detects on the key, per
4683
+ # `vscodeAdapter.ts`).
4684
+ missing_prerequisites = next(
4685
+ (c.missing for c in checks if c.name == "hostPrerequisites"), None
4686
+ )
4687
+ data = {
4688
+ "generatedAt": _generated_at(),
4689
+ "summary": summarise(checks),
4690
+ "checks": [c.as_dict() for c in checks],
4691
+ "nextSteps": next_steps(checks),
4692
+ "missingPrerequisites": missing_prerequisites,
4693
+ }
4694
+ except Exception as err: # noqa: BLE001
4695
+ # The port's most-repeated defect class: an uncaught exception escapes as
4696
+ # a raw traceback, stdout stays empty, and the extension renders nothing
4697
+ # with no error on either side. Every probe above is already guarded, and
4698
+ # so is the one thing `_collect` calls that is not a probe: its `on_check`
4699
+ # callback prints through `_print_stream_lines`, which guards its own
4700
+ # failures (see its docstring) instead of letting them unwind here and
4701
+ # discard a diagnosis that already finished -- so anything still reaching
4702
+ # here is a tan bug -- reported as one, with an envelope. INTERNAL_FAILURE,
4703
+ # not DOCTOR_FAILURE: the host was never diagnosed, and claiming it is
4704
+ # unhealthy would be a fabricated verdict.
4705
+ exit_code = ExitCode.INTERNAL_FAILURE
4706
+ data = None
4707
+ issues = [Issue("doctor.internal-failure", "error", f"{type(err).__name__}: {err}")]
4708
+
4709
+ # tan-cli#263 review: this is the "tan doctor says ready, 0 issues"
4710
+ # report -- a `.alp/sdk-path` pin that silently missed must show up here,
4711
+ # not just on a `sdk current` a suspicious operator has to think to run.
4712
+ pin_issue = project_pin_issue(sdk_broken_pin, sdk_tier)
4713
+ if pin_issue is not None:
4714
+ issues = [pin_issue, *issues]
4715
+ foreign_issue = global_default_foreign_project_issue(sdk_foreign_default)
4716
+ if foreign_issue is not None:
4717
+ issues = [foreign_issue, *issues]
4718
+
4719
+ if json_mode:
4720
+ emit(Envelope("doctor", project_envelope, data, issues, exit_code, sdk=sdk))
4721
+ elif data is None:
4722
+ for issue in issues:
4723
+ print(f"{issue.severity}: {issue.message}", file=sys.stderr)
4724
+ else:
4725
+ # tan-cli#375: an Issue with no backing Check -- a suppressed `--fix`
4726
+ # (`fix_suppressed_issue`) or a broken `.alp/sdk-path` pin
4727
+ # (`project_pin_issue`, tan-cli#263) -- used to vanish here
4728
+ # completely: this branch (the non-exception path) used to print
4729
+ # only the per-check lines and the summary, never `issues`. `--fix`'s
4730
+ # consent gate (tan-cli#91) is right to suppress silently TOWARDS THE
4731
+ # HOST -- it must never mutate anything unwatched -- but silence
4732
+ # towards the CUSTOMER is a different bug: the JSON envelope already
4733
+ # carried `doctor.fix-suppressed`/`sdk.project-pin-unresolved` and
4734
+ # text mode carried neither. `checks_to_issues(checks)` already turns
4735
+ # every warn/fail Check into an Issue, and each of those already
4736
+ # rendered as its own `[ warn]`/`[ fail]` block, streamed above as
4737
+ # `_collect` produced it -- reprinting them here would duplicate the
4738
+ # whole report, so this filters down to exactly the codes no Check
4739
+ # block already named. `render_doctor_footer` places these AFTER
4740
+ # every check block (already on screen) and BEFORE the summary:
4741
+ # findable without being buried mid-report, report still ends on the
4742
+ # summary line.
4743
+ # `kebab_check_name`, not the bare `c.name`: tan-cli#461 made the
4744
+ # camelCase-to-kebab conversion the one shared way a `Check.name`
4745
+ # becomes an issue-code suffix, so the set built here must be spelled
4746
+ # the same way `checks_to_issues` spells the codes it is filtering
4747
+ # against -- otherwise every check-backed issue misses the set and
4748
+ # the whole report prints twice.
4749
+ checked_codes = {c.code or f"doctor.{kebab_check_name(c.name)}" for c in checks}
4750
+ extra_issue_lines = [
4751
+ f"{issue.severity}: {issue.message}"
4752
+ for issue in issues
4753
+ if issue.code not in checked_codes
4754
+ ]
4755
+ _print_stream_lines(
4756
+ render_doctor_footer(data["checks"], data["summary"], extra_issue_lines, width),
4757
+ "footer",
4758
+ )
4759
+
4760
+ raise typer.Exit(int(exit_code))
4761
+
4762
+
4763
+ # tan-cli#261: adds the four oracle `GlobalArgs` flags this command was still
4764
+ # missing (`--all`/`--quiet`/`--target`/`--verbose`) on top of `--no-color`/
4765
+ # `--non-interactive`/`--ci`, already declared and wired into `can_prompt`
4766
+ # above; see `tan.core.global_flags`.
4767
+ doctor = accept_global_flags(doctor)