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,996 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The alp-sdk discovery ladders (tan-cli#408) -- `--sdk-root` > project pin >
3
+ machine-global default > positional discovery, in the NARROW and WIDE
4
+ flavours documented on `resolve_sdk_root_ladder` / `resolve_sdk_root_wide`
5
+ below -- plus the divergence warning between them and the interpreter
6
+ resolution that rides on the same tiers.
7
+
8
+ **Moved here from `tan/commands/build_cmd.py`, verbatim logic, tan-cli#408.**
9
+ Nineteen modules resolved the SDK through this code while it lived in a
10
+ 2000+-line COMMAND module, and one of them was `tan/envelope.py` -- core
11
+ infrastructure every command's envelope passes through -- reaching back into
12
+ a command for it via a function-scoped import whose own comment said why:
13
+ "`build_cmd` imports this module at module level, so a top-level import here
14
+ would be circular." That is the dependency inverted: core importing from a
15
+ command. This module is the fix -- `tan.core` importing no command module
16
+ (the same invariant `tan.core.shapes` already states and relies on), so
17
+ `envelope.py` can import [`sdk_ladder_divergence_issue`] at the top of the
18
+ file rather than inside the one method that needed it.
19
+
20
+ **Finished here, tan-cli#408 review follow-up.** The first pass of this
21
+ extraction left `resolve_sdk_tiered` -- the single NARROW tiered resolver
22
+ both ladders share -- behind in `tan.commands.sdk_cmd`, reached through a
23
+ function-scoped import in each ladder function, on the reasoning that it was
24
+ "a much larger, separately-scoped change." Review of that first pass found
25
+ the opposite: `resolve_sdk_tiered` and the dozen filesystem-primitive
26
+ functions under it (the tier-pointer reads, the registry lookup, the two
27
+ positional-discovery walks) touch none of `typer`, `tan.envelope`,
28
+ `tan.output_format` or `tan.exit_codes` -- their only non-stdlib dependencies
29
+ were already `tan.core.shapes` and `tan.core.sdk_default_registry`, both
30
+ core. The whole cluster moved here in the same shape, verbatim, along with
31
+ `ActiveSdk` and the two `Issue` builders (`project_pin_issue`,
32
+ `global_default_foreign_project_issue`) `envelope.py` needs through
33
+ [`sdk_resolution_issues`]. Both ladder functions call `resolve_sdk_tiered`
34
+ directly now -- no function-scoped import left inside either one, because
35
+ the cycle it used to dodge (`sdk_discovery` -> `sdk_cmd` -> back into this
36
+ module's own ladders) no longer exists once `resolve_sdk_tiered` lives here
37
+ too.
38
+
39
+ `Issue` (`tan.envelope`, the wire type every `*_issue`/`*_issues` function
40
+ below returns) is the one name this module still cannot import at module
41
+ level: `tan.envelope` imports THIS module at module level
42
+ (`sdk_ladder_divergence_issue`, `sdk_resolution_issues`), so a top-level
43
+ import back would recreate exactly the cycle this extraction removes, just
44
+ with the two modules swapped. `TYPE_CHECKING` supplies the name for every
45
+ annotation below, which is what a real type checker (mypy, an IDE) resolves
46
+ against -- `from __future__ import annotations` defers every annotation to
47
+ string form, so this costs nothing at runtime, and it is the same idiom
48
+ `sdk_cmd._releases_opener` already uses for `OpenerDirector` one file over.
49
+ Each function that actually CONSTRUCTS an `Issue` imports it again,
50
+ function-scoped, at the point it runs. The deferral is safe NOT because
51
+ some caller has necessarily already imported `tan.envelope` first (tan-cli#408
52
+ review, nit: an earlier draft of this paragraph claimed exactly that, and it
53
+ does not hold -- measured in a process that imported ONLY this module,
54
+ `'tan.envelope' in sys.modules` is `False` right up to the call, and the
55
+ deferred import still succeeds). It is safe because by the time any function
56
+ in this module RUNS, this module's OWN top-level execution has already
57
+ finished and `tan.core.sdk_discovery` is fully registered in `sys.modules` --
58
+ so `tan.envelope`'s own top-level `from tan.core.sdk_discovery import
59
+ sdk_ladder_divergence_issue, sdk_resolution_issues` finds a complete module,
60
+ never a partially-initialized one, however `tan.envelope` first got
61
+ imported. A genuine cycle can only happen while ONE of the two modules is
62
+ still in the middle of its OWN top-level execution; nothing here runs at
63
+ that time.
64
+
65
+ **`typing.get_type_hints()` still raises on these annotations, and that is
66
+ not fixed by the `TYPE_CHECKING` guard -- it cannot be, and no wrapper
67
+ should try (tan-cli#408 review, minor 2 follow-up).** `TYPE_CHECKING` is
68
+ `False` at runtime, so the guarded import never executes and `Issue` is
69
+ never bound in this module's real `__dict__`; `get_type_hints()` resolves a
70
+ string annotation by `eval`-ing it against `func.__globals__`, which IS that
71
+ same `__dict__`, so `NameError: name 'Issue' is not defined` is still the
72
+ measured result of calling it on any function below that names `Issue`.
73
+ Making it resolve for real would mean binding the real `Issue` class at
74
+ module level, which is the exact cycle this whole module exists to avoid --
75
+ there is no way to have both. This is the identical tradeoff
76
+ `_releases_opener`'s own docstring already makes for `OpenerDirector`, for
77
+ the identical reason: `get_type_hints()` is live in this codebase
78
+ (`tan.core.global_flags.accept_global_flags`), but it is called ONLY on each
79
+ `*_cmd.py` module's top-level typer command callback -- never on an internal
80
+ helper in `tan/core/`, here or in `sdk_cmd.py` -- so the two never meet in
81
+ production. A guard test asserting `get_type_hints()` resolves across this
82
+ module's public functions was considered and rejected for the same reason:
83
+ it would be false by construction for every `Issue`-returning one, so it
84
+ would not catch a real regression, only encode this already-understood and
85
+ already-accepted limitation as a failing assertion.
86
+ """
87
+ from __future__ import annotations
88
+
89
+ import json
90
+ import os
91
+ from dataclasses import dataclass
92
+ from pathlib import Path, PurePosixPath, PureWindowsPath
93
+ from typing import TYPE_CHECKING, Any
94
+
95
+ if TYPE_CHECKING: # pragma: no cover
96
+ # Type-checker only -- see the module docstring for why this cannot be a
97
+ # runtime import. `get_type_hints()` needs this name in the module
98
+ # namespace to resolve the `Issue | None` / `list[Issue]` annotations
99
+ # below; it is never evaluated at import time (`TYPE_CHECKING` is False
100
+ # in a real run), so it adds no runtime dependency on `tan.envelope`.
101
+ from tan.envelope import Issue
102
+
103
+ from tan.core.sdk_default_registry import (
104
+ deepest_covering_entry,
105
+ parse_registry,
106
+ parse_registry_updated_at,
107
+ registry_path,
108
+ )
109
+ from tan.core.shapes import SDK_MARKER, is_sdk_root
110
+ from tan.core.venv import venv_python
111
+
112
+
113
+ def _abs_posix(path: str) -> str:
114
+ """Rust's `normalize_path(cwd.join(p))` + `to_posix`: cwd-anchored,
115
+ lexically normalised, forward slashes.
116
+
117
+ `os.path.abspath`, NOT `Path.resolve()` -- abspath is purely lexical, so a
118
+ project reached through a symlink keeps the name the user typed, and a path
119
+ that does not exist yet still resolves. `resolve()` would rewrite both, and
120
+ the divergence guard compares STRINGS: rewriting one side's symlink but not
121
+ the other's is exactly how that guard starts firing on a healthy project.
122
+
123
+ Forward slashes because the result is substituted into `${PROJECT_ROOT}`
124
+ and lands in a CMake `-D` argument, where a Windows backslash is an escape.
125
+ """
126
+ return os.path.abspath(path).replace("\\", "/")
127
+
128
+
129
+ def discover_sdk_root(workspace_root: Path) -> Path | None:
130
+ """Find an alp-sdk checkout near `workspace_root`, mirroring Rust's
131
+ `discover_sdk_root` (`crates/tan-cli/src/util.rs`) candidate for
132
+ candidate: the root itself, then its CHILD `alp-sdk/`, then the sibling
133
+ `../alp-sdk`, then `../alp-sdk-upstream`, and only if none of those hit,
134
+ the nearest ENCLOSING checkout.
135
+
136
+ The child comes before the siblings deliberately (tan-cli #218): `tan
137
+ bootstrap` clones into `<ws>/alp-sdk`, so at that moment the checkout is a
138
+ CHILD of the cwd -- it only becomes the documented sibling once the user
139
+ has cd'd into a project. Discovery that checks root, siblings and
140
+ ancestors but not a child reports "alp-sdk root is unresolved" with the
141
+ checkout sitting right there.
142
+
143
+ A fixed candidate list, never a directory scan: probing `iterdir()` up the
144
+ whole ancestor chain reads a developer's entire home and drive root, which
145
+ is both slow and non-hermetic (it lets an unrelated checkout elsewhere on
146
+ the machine decide what a test resolves).
147
+
148
+ Deliberately just the positional walk, not the full ladder -- the project
149
+ pin and machine-global default tiers that outrank this one live in
150
+ [`resolve_sdk_root_ladder`], below, which every `--sdk-root`-less caller
151
+ should use instead of calling this directly (this function remains that
152
+ ladder's own final fallback tier, and the one place that still calls it
153
+ alone is `tan-cli#218`'s own regression test).
154
+ """
155
+ parent = workspace_root.parent
156
+ for candidate in (
157
+ workspace_root,
158
+ workspace_root / "alp-sdk",
159
+ parent / "alp-sdk",
160
+ parent / "alp-sdk-upstream",
161
+ ):
162
+ if is_sdk_root(candidate):
163
+ return candidate
164
+ for ancestor in workspace_root.parents:
165
+ if is_sdk_root(ancestor):
166
+ return ancestor
167
+ return None
168
+
169
+
170
+ # ── filesystem primitives (every failure is a value, never an exception) ────
171
+ # Moved verbatim from `tan/commands/sdk_cmd.py`, tan-cli#408 review follow-up
172
+ # -- see the module docstring. `check_sdk_readiness`/`cached_sdk_versions`,
173
+ # which stay in `sdk_cmd.py` (they are readiness REPORTING, not resolution),
174
+ # import `_read_file`/`_has_loader_script`/`_home_alp_dir` back from here.
175
+
176
+
177
+ def _read_file(path: Path) -> str | None:
178
+ """`tan_core`'s injected `read_file`: contents, or `None` on ANY read
179
+ failure -- missing, a directory, permission-denied, non-UTF-8 bytes.
180
+
181
+ `encoding="utf-8"` explicitly (I-27): a bare `read_text()` decodes with the
182
+ host locale, so a pointer file or `sdk_version.yaml` carrying one non-ASCII
183
+ byte raises `UnicodeDecodeError` on a cp1252 Windows host and passes on
184
+ ubuntu CI. Swallowing it to `None` is what the Rust does and is what keeps
185
+ an unreadable file a reported fact instead of a traceback.
186
+ """
187
+ try:
188
+ return path.read_text(encoding="utf-8")
189
+ except (OSError, UnicodeDecodeError):
190
+ return None
191
+
192
+
193
+ def _has_loader_script(root: Path) -> bool:
194
+ """True when `root/scripts/alp_project.py` exists -- `util.rs`'s
195
+ `has_loader_script`. `Path.exists()` swallows its own `OSError`/`ValueError`
196
+ (a too-long path, an illegal name), so a pathological pointer value reads as
197
+ "not an SDK" rather than raising out of a tier lookup."""
198
+ return root.joinpath(*SDK_MARKER).exists()
199
+
200
+
201
+ def _to_posix(path: Path) -> str:
202
+ """`tan_core::project::to_posix`. The discovery tier's result is a reported
203
+ path, and every golden-pinned path field is platform-identical forward
204
+ slashes; `Path` renders `\\` on Windows."""
205
+ return str(path).replace("\\", "/")
206
+
207
+
208
+ def _home_alp_dir() -> Path:
209
+ """`~/.alp` -- `USERPROFILE` on Windows else `HOME`, falling back to `.`
210
+ when neither is set (`util.rs`'s `home_alp_dir`). Home of the global default
211
+ pointer and the install cache, and the reason the conformance harness
212
+ overrides BOTH variables: a developer's real `~/.alp/sdk-default` would
213
+ otherwise decide what `sdk current` reports."""
214
+ home = os.environ.get("USERPROFILE" if os.name == "nt" else "HOME")
215
+ return Path(home or ".") / ".alp"
216
+
217
+
218
+ def sdk_search_summary(workspace_root: Path) -> str:
219
+ """tan-cli#1423: where `resolve_sdk_root_ladder` looked, tier by tier, for
220
+ a `*.sdk-root-*` refusal to quote beside its `--sdk-root` remedy. There is
221
+ no env-var remedy to add: `ALP_SDK_ROOT` is not a tier (see that ladder's
222
+ docstring)."""
223
+ root = Path(workspace_root).absolute()
224
+ near = ", ".join(
225
+ f"`{_to_posix(p)}`"
226
+ for p in (root, root / "alp-sdk", root.parent / "alp-sdk", root.parent / "alp-sdk-upstream")
227
+ )
228
+ return (
229
+ f"Neither `--sdk-root`, the project pin `{_to_posix(root / '.alp' / 'sdk-path')}` nor "
230
+ f"the global default `{_to_posix(_home_alp_dir() / 'sdk-default')}` named a usable "
231
+ f"checkout, and none was found at {near} or in a directory above "
232
+ f"`{_to_posix(root)}`."
233
+ )
234
+
235
+
236
+ def with_sdk_search(
237
+ message: str, workspace_root: Path | str, sdk_root_arg: str | None = None
238
+ ) -> str:
239
+ """tan-cli#1463: the ONE route every `<cmd>.sdk-root-*` refusal takes to name
240
+ where tan looked. Appends `sdk_search_summary` to `message`.
241
+
242
+ A REJECTED `--sdk-root` is terminal (I-31): no other tier was tried, so
243
+ `message` is returned untouched -- claiming the ladder was searched would be
244
+ false, and `rejected_sdk_root_message` already names the typed value.
245
+ """
246
+ if (sdk_root_arg or "").strip():
247
+ return message
248
+ return f"{message} {sdk_search_summary(Path(workspace_root))}"
249
+
250
+
251
+ def _read_pointer_json(pointer: Path) -> dict[str, Any] | None:
252
+ """Parse a pointer file (`.alp/sdk-path`, `~/.alp/sdk-default`) into its
253
+ dict, or `None` on ANY failure -- missing, unreadable, invalid JSON, or a
254
+ non-dict shape (a list). Shared by every field reader over this shape so
255
+ `_pointer_target` and `_pointer_written_for` can never disagree about what
256
+ counts as an unreadable pointer.
257
+ """
258
+ if not pointer.exists():
259
+ return None
260
+ raw = _read_file(pointer)
261
+ if raw is None:
262
+ return None
263
+ try:
264
+ parsed = json.loads(raw)
265
+ except ValueError:
266
+ return None
267
+ return parsed if isinstance(parsed, dict) else None
268
+
269
+
270
+ def _pointer_target(pointer: Path) -> str | None:
271
+ """The `sdkPath` out of a `{"sdkPath": ..., "updatedAt": ...}` pointer file.
272
+
273
+ One function for both pointers -- `tan_core`'s `resolve_active_sdk`
274
+ (`<workspace>/.alp/sdk-path`) and `resolve_global_default_sdk`
275
+ (`~/.alp/sdk-default`) are the same read of the same shape at two paths.
276
+ Every failure is `None`, matching the Rust's `.ok()?` chain: a hand-edited
277
+ pointer holding invalid JSON, a list, or no `sdkPath` at all must fall
278
+ through to the next tier, not abort the command.
279
+ """
280
+ parsed = _read_pointer_json(pointer)
281
+ value = parsed.get("sdkPath") if parsed is not None else None
282
+ return value if isinstance(value, str) else None
283
+
284
+
285
+ def _pointer_written_for(pointer: Path) -> str | None:
286
+ """The optional `writtenFor` field alongside `sdkPath`/`updatedAt` in
287
+ `~/.alp/sdk-default` -- which project's bootstrap relocation last wrote
288
+ this machine-global pointer (tan-cli#464).
289
+
290
+ `None` covers both "no opinion" cases the same way, on purpose: a pointer
291
+ written by an older tan that predates this field, and one this tan wrote
292
+ for a run that never relocated a checkout FOR a project at all. Neither is
293
+ a claim that no other project wrote the pointer -- it is the absence of
294
+ evidence, and `resolve_sdk_tiered` must never manufacture a warning out of
295
+ it.
296
+
297
+ A non-`str`, empty, or non-absolute value is ALSO `None` (measured
298
+ tan-cli#464 review regression): `writtenFor: ""` used to pass the bare
299
+ `isinstance(value, str)` check here and reach `_workspace_under(ws, "")`,
300
+ which resolves `Path("")` to the process's cwd -- so whether the foreign
301
+ warning fired depended on where the caller happened to be standing, not
302
+ on anything the pointer actually recorded. Every OTHER project root this
303
+ field can legitimately hold is written by `bootstrap_cmd._run` as an
304
+ already-absolute `root` (`Project.resolved`'s own contract), so rejecting
305
+ a relative or blank one here is not narrowing real coverage ON THE
306
+ WRITER'S OWN PLATFORM -- but `~/.alp/sdk-default` is one pointer shared by
307
+ every tan on the host, and a bare `Path(value).is_absolute()` is answered
308
+ by whichever pathlib flavour the READER's OS picked: `PureWindowsPath`
309
+ needs a drive letter, so a legitimate `"/home/u/projB"` written by a
310
+ Linux/macOS tan degraded to "no opinion" the moment a Windows tan read it
311
+ back, and symmetrically `"C:/projB"` from a Windows writer is non-absolute
312
+ to `PurePosixPath` on the other two. Accepted here when EITHER
313
+ `PurePosixPath` or `PureWindowsPath` calls it absolute, so a value either
314
+ platform's tan legitimately wrote still counts, while `""`/`"."`/a bare
315
+ relative segment (`"projB/ws"`)/a drive-relative `"C:projB"` stay `None`
316
+ under both -- the safe degradation is unchanged, only which absolute
317
+ shapes clear it.
318
+ """
319
+ parsed = _read_pointer_json(pointer)
320
+ value = parsed.get("writtenFor") if parsed is not None else None
321
+ if not isinstance(value, str) or not value:
322
+ return None
323
+ if not PurePosixPath(value).is_absolute() and not PureWindowsPath(value).is_absolute():
324
+ return None
325
+ return value
326
+
327
+
328
+ def _workspace_under(workspace_root: Path, root: str) -> bool:
329
+ """Whether `workspace_root` IS `root`, or sits somewhere below it --
330
+ resolved on both sides so a `..`, a symlink, or Windows' case-folding
331
+ cannot spoof a match (mirrors `tan.core.fs_confine.resolve_confined`'s own
332
+ reasoning for the same comparison). Any resolution failure (a path shape
333
+ the host rejects outright) reads as "not under it" -- the caller treats
334
+ that as grounds for a WARNING, never a hard failure.
335
+
336
+ `RuntimeError` is caught alongside `OSError`/`ValueError` (review, #904,
337
+ minor 1): `Path.resolve()` re-raises an `ELOOP` (a symlink loop) as a
338
+ `RuntimeError` ("Symlink loop from ...") rather than an `OSError` --
339
+ pathlib's own choice, not this module's -- so a registry origin or a
340
+ legacy `writtenFor` naming a symlink loop must degrade the same as any
341
+ other unresolvable path, not raise out of a tier lookup. Pre-existing
342
+ from tan-cli#464 (both pointer tiers shared this gap before #466 added a
343
+ third caller of the same pattern); left uncaught here would contradict
344
+ `parse_registry`'s own "never raises" contract one function over.
345
+ """
346
+ try:
347
+ return workspace_root.resolve().is_relative_to(Path(root).resolve())
348
+ except (OSError, ValueError, RuntimeError):
349
+ return False
350
+
351
+
352
+ def _resolved_origin_depth_key(root: str) -> str:
353
+ """`root`, resolved the same way `_workspace_under` resolves it for
354
+ containment -- reused as `sdk_default_registry.deepest_covering_entry`'s
355
+ depth-ranking key so ranking can never diverge from `covers`'s own notion
356
+ of "contains" (review, #904, major 1: a symlinked origin made the raw
357
+ registry-key string length disagree with `_workspace_under`'s resolved
358
+ containment test, so the WRONG registered SDK could win the "deepest"
359
+ tie-break with no warning at all).
360
+
361
+ Any resolution failure (including a symlink loop, `RuntimeError`) falls
362
+ back to the raw string rather than raising. IN PRODUCTION, via
363
+ `deepest_covering_entry`, this is only ever called after `covers` has
364
+ already resolved this SAME `root` successfully for this SAME candidate,
365
+ so a failure here would mean the filesystem changed between the two
366
+ calls -- treated as a same-caliber non-event as every other best-effort
367
+ read in this module, not a crash out of a tier lookup. That reasoning
368
+ made the `RuntimeError` arm of this guard genuinely hard to reach through
369
+ the production call graph alone (review, #904 second round, minor: a
370
+ narrowed `except (OSError, ValueError)` left the entire existing suite
371
+ green). `test_resolved_origin_depth_key_degrades_a_symlink_loop_instead_
372
+ of_raising` closes that by calling this function DIRECTLY, with no prior
373
+ `covers` call in front of it -- proving the degrade independent of
374
+ whether any production call order happens to exercise it.
375
+ """
376
+ try:
377
+ return str(Path(root).resolve())
378
+ except (OSError, ValueError, RuntimeError):
379
+ return root
380
+
381
+
382
+ def _read_global_sdk_registry_raw() -> str | None:
383
+ """`~/.alp/sdk-defaults.json`'s raw text, or `None` when unreadable --
384
+ read ONCE and shared by both views `resolve_sdk_tiered` needs
385
+ (`parse_registry`'s flattened `sdkPath` view and
386
+ `parse_registry_updated_at`'s recency view, review #904 second round,
387
+ major): reading the file twice would let a concurrent `tan bootstrap`'s
388
+ write land BETWEEN the two reads, so one view could see an origin the
389
+ other one does not -- not a crash (`deepest_covering_entry`'s
390
+ `updated_at.get(origin, "")` tolerates a missing key the same as a
391
+ pre-tie-break registry), but an avoidable extra race window for a file
392
+ this module already goes out of its way to read atomically-consistent
393
+ once.
394
+
395
+ Reuses `_read_file` -- the same swallow-every-read-failure primitive
396
+ every pointer read in this file already goes through -- rather than a
397
+ second bespoke try/except, so a non-UTF-8 registry degrades exactly like
398
+ a non-UTF-8 pointer does.
399
+ """
400
+ return _read_file(registry_path(_home_alp_dir()))
401
+
402
+
403
+ def _nearest_ancestor_sdk(start: Path) -> str | None:
404
+ """The nearest ENCLOSING checkout, walking `start`'s parents upward.
405
+ `start` itself is deliberately not probed -- every caller checks it first
406
+ (`tan_core::project::nearest_ancestor_sdk`).
407
+
408
+ This is what makes the documented Quickstart resolve: `tan --project
409
+ examples/<cat>/<name>` puts the workspace root levels BELOW the checkout it
410
+ was invoked from (tan-cli #101). The walk yields at most ONE path, so it can
411
+ never turn an otherwise-unambiguous resolution into an ambiguous `None`.
412
+ """
413
+ for ancestor in start.parents:
414
+ if _has_loader_script(ancestor):
415
+ return _to_posix(ancestor)
416
+ return None
417
+
418
+
419
+ def discover_workspace_sdk(workspace_root: Path) -> str | None:
420
+ """Auto-discovery for the `discovery` tier: the workspace root itself or its
421
+ SIBLING `../alp-sdk`, else the nearest enclosing checkout. Two or more
422
+ candidates is ambiguous, which is `None` -- not a choice.
423
+
424
+ **Deliberately NOT [`discover_sdk_root`], above**, which is a different
425
+ Rust function (`util.rs`'s `discover_sdk_root`) with a WIDER candidate set:
426
+ it also probes the child `<ws>/alp-sdk` and `../alp-sdk-upstream`, and takes
427
+ the first match rather than requiring uniqueness. `sdk current` must mirror
428
+ `tan_core::discover_workspace_sdk` instead, per `resolve_sdk_tiered`'s own
429
+ doc comment: the tier it reports has to be what build/validate/doctor would
430
+ actually resolve here, or `sourceTier: "discovery"` names a path no other
431
+ command agrees with. Reusing the build-side helper would be the tempting
432
+ de-duplication and it would make the report lie.
433
+ """
434
+ candidates: list[str] = []
435
+ lateral_hit = False
436
+
437
+ if _has_loader_script(workspace_root):
438
+ # Normalised BEFORE the dedup below: on Windows a root spelled with
439
+ # backslashes and its `parent/alp-sdk` sibling can name the same
440
+ # directory yet compare unequal as strings, which counted one SDK twice
441
+ # and reported "ambiguous".
442
+ candidates.append(_to_posix(workspace_root))
443
+ lateral_hit = True
444
+
445
+ # `parent != self` is pathlib's spelling of Rust's `Path::parent()` returning
446
+ # `None`: at a filesystem/drive root `Path("C:/").parent` is `Path("C:/")`
447
+ # itself, so an unguarded probe would invent a `C:/alp-sdk` candidate the
448
+ # Rust never considers -- flipping `sourceTier` from `none` to `discovery`
449
+ # for anyone whose cwd is a drive root.
450
+ parent = workspace_root.parent
451
+ if parent != workspace_root:
452
+ sibling = _to_posix(parent / "alp-sdk")
453
+ if _has_loader_script(Path(sibling)):
454
+ if sibling not in candidates:
455
+ candidates.append(sibling)
456
+ lateral_hit = True
457
+
458
+ # A strict fallback, gated on whether THIS folder's lateral probes answered
459
+ # -- never on the candidate count, which stays unchanged for a folder that
460
+ # resolved perfectly well but deduped against an earlier one.
461
+ if not lateral_hit:
462
+ ancestor = _nearest_ancestor_sdk(workspace_root)
463
+ if ancestor is not None:
464
+ candidates.append(ancestor)
465
+
466
+ return candidates[0] if len(candidates) == 1 else None
467
+
468
+
469
+ # ── the four-tier precedence chain ──────────────────────────────────────────
470
+
471
+
472
+ @dataclass(frozen=True)
473
+ class ActiveSdk:
474
+ """What `sdk current` reports: the active path (or `None`) and the tier that
475
+ produced it. `tier` is the wire string, camelCase, from `SdkSourceTier`.
476
+
477
+ `broken_project_pin` (tan-cli#263) is the raw `sdkPath` a workspace
478
+ `.alp/sdk-path` pointer held when that file existed but its target failed
479
+ the loader-script check -- `None` on every other path, including "no
480
+ pointer file at all". Set once, in the one tier that can discover it,
481
+ then carried through every LOWER tier this call falls through to: a caller
482
+ that reports the tier which finally answered must still be able to say a
483
+ pin existed and did not, rather than silently looking as deliberate as a
484
+ workspace that was never pinned in the first place. Distinct from a
485
+ SIXTH `sourceTier` value on purpose -- the tier that actually supplied
486
+ `path` stays accurate; this is a supplementary fact about a REJECTED
487
+ candidate, reported by the caller via `issues[]` instead.
488
+
489
+ `foreign_global_default_for` (tan-cli#464) is the SAME shape of fact for a
490
+ different silence: `~/.alp/sdk-default` is machine-global and
491
+ last-writer-wins across every project that ever relocated a checkout on
492
+ this host, so a caller resolving through the `globalDefault` tier from a
493
+ workspace that is neither the project the pointer was last written for
494
+ nor under the SDK it names is reading an answer left behind by SOMEONE
495
+ ELSE'S bootstrap -- silently, before this. Set only on the `globalDefault`
496
+ tier (the only one this ambiguity can apply to); `None` covers both "the
497
+ pointer was written for (or covers) this workspace" and "the pointer
498
+ predates `writtenFor` and carries no opinion at all" -- deliberately the
499
+ same as `broken_project_pin`'s "no pointer" case, since neither is
500
+ evidence of a mismatch."""
501
+
502
+ path: str | None
503
+ tier: str
504
+ broken_project_pin: str | None = None
505
+ foreign_global_default_for: str | None = None
506
+
507
+
508
+ def resolve_sdk_tiered(sdk_root: str | None, workspace_root: Path) -> ActiveSdk:
509
+ """`--sdk-root` > project pin > global default > discovery > nothing
510
+ (`util.rs`'s `resolve_sdk_tiered` + `tan_core`'s `resolve_sdk_source_tier`).
511
+
512
+ `--sdk-root` is TERMINAL and returned as-is **even when it is not a
513
+ checkout** (I-31). That is not an oversight to tidy: a bad `--sdk-root`
514
+ surfaces as a `missing` readiness naming the path the user typed, where the
515
+ Pythonic `if not valid: continue` would silently fall through to a lower
516
+ tier and report a DIFFERENT SDK than the one they asked for.
517
+
518
+ Both pointer tiers are best-effort by contrast -- each is used only while it
519
+ still points at a real checkout -- so a stale pointer falls through instead
520
+ of locking the user out of every command. The project pin's own fallthrough
521
+ is not silent, though (tan-cli#263): its raw target survives on the
522
+ returned `ActiveSdk.broken_project_pin` no matter which lower tier ends up
523
+ answering, so a caller can report "this workspace IS pinned, and the pin
524
+ does not resolve" instead of looking indistinguishable from a workspace
525
+ that was never pinned at all -- exactly what let a `tan init` run under a
526
+ since-moved project silently re-resolve a DIFFERENT alp-sdk checkout with
527
+ `ok: true`, `issues: []`.
528
+
529
+ The `globalDefault` tier carries the SAME kind of silence one tier down
530
+ (tan-cli#464): it is one pointer shared by every project on the host, so a
531
+ caller resolving through it from a workspace the pointer was not written
532
+ for -- and that is not even under the SDK it names -- gets no signal that
533
+ a DIFFERENT project's bootstrap relocation is what actually decided this
534
+ answer. `foreign_global_default_for` names that project when this run hit
535
+ exactly that case; `None` when the pointer covers this caller, or predates
536
+ `writtenFor` and has no opinion.
537
+
538
+ tan-cli#466 makes that answer CORRECT, not just disclosed, without moving
539
+ this tier or adding a sixth `SdkSourceTier`: before falling to the single
540
+ legacy pointer, this tier first consults `~/.alp/sdk-defaults.json`, an
541
+ origin-keyed registry every relocating `tan bootstrap` writes alongside
542
+ (never instead of) the legacy file, and picks the DEEPEST registry key
543
+ that contains `workspace_root` (`sdk_default_registry.
544
+ deepest_covering_entry`, using this same `_workspace_under` containment
545
+ test, with a resolved-depth TIE broken by which entry's `updatedAt` is
546
+ most recent -- review, #904 second round, major: two distinct raw
547
+ origins that alias the same directory, e.g. a symlink bootstrapped once
548
+ and the same directory bootstrapped again later through its real path,
549
+ resolve to identical depth). A registry hit still reports `sourceTier:
550
+ "globalDefault"` -- it IS the machine-default mechanism, keyed -- and
551
+ never carries `foreign_global_default_for`: a caller a registry entry
552
+ was written FOR is, by construction, not reading someone else's answer.
553
+ Only when NO registry entry covers the caller does resolution fall
554
+ through to the legacy pointer exactly as it did before this issue,
555
+ foreign-warning included.
556
+ """
557
+ flag = (sdk_root or "").strip()
558
+ if flag:
559
+ return ActiveSdk(flag, "sdkRootFlag")
560
+
561
+ broken_project_pin: str | None = None
562
+ pin = _pointer_target(workspace_root / ".alp" / "sdk-path")
563
+ if pin is not None:
564
+ if _has_loader_script(Path(pin)):
565
+ return ActiveSdk(pin, "projectPin")
566
+ broken_project_pin = pin
567
+
568
+ registry_raw = _read_global_sdk_registry_raw()
569
+ registry_hit = deepest_covering_entry(
570
+ parse_registry(registry_raw),
571
+ workspace_root,
572
+ covers=_workspace_under,
573
+ has_loader_script=_has_loader_script,
574
+ resolve_origin=_resolved_origin_depth_key,
575
+ updated_at=parse_registry_updated_at(registry_raw),
576
+ )
577
+ if registry_hit is not None:
578
+ _origin, registry_sdk_path = registry_hit
579
+ return ActiveSdk(registry_sdk_path, "globalDefault", broken_project_pin, None)
580
+
581
+ default_pointer = _home_alp_dir() / "sdk-default"
582
+ default = _pointer_target(default_pointer)
583
+ if default is not None and _has_loader_script(Path(default)):
584
+ written_for = _pointer_written_for(default_pointer)
585
+ foreign = (
586
+ written_for
587
+ if written_for is not None
588
+ and not _workspace_under(workspace_root, written_for)
589
+ and not _workspace_under(workspace_root, default)
590
+ else None
591
+ )
592
+ return ActiveSdk(default, "globalDefault", broken_project_pin, foreign)
593
+
594
+ discovered = discover_workspace_sdk(workspace_root)
595
+ if discovered is not None:
596
+ return ActiveSdk(discovered, "discovery", broken_project_pin)
597
+
598
+ return ActiveSdk(None, "none", broken_project_pin)
599
+
600
+
601
+ def project_pin_issue(broken_project_pin: str | None, tier: str) -> Issue | None:
602
+ """The tan-cli#263 warning for an unresolvable `.alp/sdk-path` project pin
603
+ -- shared by EVERY caller of `resolve_sdk_tiered` (directly, or through
604
+ `resolve_sdk_root_ladder`/`resolve_sdk_root_wide`, below), not just `sdk
605
+ current`. `None` when nothing was rejected, so every call site can do
606
+ `issue = project_pin_issue(broken, tier); if issue: issues.append(issue)`
607
+ unconditionally.
608
+
609
+ `tan build` is the caller this matters most for: a workspace whose pin
610
+ silently misses still gets a real build, against whichever SDK the ladder
611
+ fell through to, with no signal it was not the one `.alp/sdk-path` names
612
+ -- `sdk current` alone only helps someone already suspicious enough to run
613
+ it."""
614
+ if broken_project_pin is None:
615
+ return None
616
+ # Function-scoped: see the module docstring -- `tan.envelope` imports this
617
+ # module at module level, so a top-level import back would be circular.
618
+ from tan.envelope import Issue
619
+
620
+ return Issue(
621
+ "sdk.project-pin-unresolved",
622
+ "warning",
623
+ f'.alp/sdk-path names "{broken_project_pin}", which does not resolve '
624
+ f"to an alp-sdk checkout from the current directory -- falling "
625
+ f"through to the {tier} tier instead.",
626
+ )
627
+
628
+
629
+ def global_default_foreign_project_issue(foreign_global_default_for: str | None) -> Issue | None:
630
+ """The tan-cli#464 warning for a `globalDefault` answer that a DIFFERENT
631
+ project's bootstrap relocation actually decided: `~/.alp/sdk-default` is
632
+ one pointer, shared and last-writer-wins across every project that ever
633
+ relocates a checkout on this host, so the earlier of two projects can
634
+ silently start resolving the later one's SDK the moment the later one
635
+ bootstraps -- `ok: true`, `issues: []`, same as a caller that was never
636
+ pinned at all (the maintainer's own #464 repro).
637
+
638
+ `None` when `resolve_sdk_tiered` found nothing to warn about -- the
639
+ pointer covers this caller, or it predates `writtenFor` and carries no
640
+ opinion -- so every call site can do `issue =
641
+ global_default_foreign_project_issue(active.foreign_global_default_for);
642
+ if issue: issues.append(issue)` unconditionally, exactly like
643
+ `project_pin_issue`.
644
+
645
+ tan-cli#466: `resolve_sdk_tiered` sets `foreign_global_default_for` ONLY
646
+ when resolution fell all the way through to this single legacy pointer
647
+ -- never for a `~/.alp/sdk-defaults.json` registry hit, which by
648
+ construction was written FOR the resolving workspace. Resolution itself
649
+ is unchanged by THIS warning specifically: the root `sdk current`
650
+ reports here is the same root it would have reported before this
651
+ function existed -- #466 is what changed the root itself, for the
652
+ workspaces a registry entry now covers.
653
+ """
654
+ if foreign_global_default_for is None:
655
+ return None
656
+ # Function-scoped: see `project_pin_issue`, above.
657
+ from tan.envelope import Issue
658
+
659
+ return Issue(
660
+ "sdk.global-default-foreign-project",
661
+ "warning",
662
+ f'The machine-global default SDK (~/.alp/sdk-default) was last set by '
663
+ f'a bootstrap relocation in "{foreign_global_default_for}", not by one '
664
+ f"here -- this workspace is falling through to that project's SDK, "
665
+ f"which may not be the checkout you expect. Pin this workspace "
666
+ f"explicitly with `--sdk-root <path>`, or bootstrap here, to stop "
667
+ f"relying on the shared default.",
668
+ )
669
+
670
+
671
+ def sdk_resolution_issues(
672
+ broken_project_pin: str | None, tier: str, foreign_global_default_for: str | None
673
+ ) -> list[Issue]:
674
+ """`project_pin_issue` + `global_default_foreign_project_issue`, together,
675
+ in the order `flash`/`size`/`image` -- the three callers this actually
676
+ has -- append them: each used to compute this pair only on the happy
677
+ path and skip it on a manifest-gate early return.
678
+
679
+ **Not the only copy.** Twelve other modules still hand-copy the pair
680
+ directly rather than through here -- most fold in a third, caller-
681
+ specific issue or use a different order, not the mechanical swap it
682
+ looks like; left as-is (tan-cli#464 review).
683
+
684
+ `[]` when neither fires -- a caller can `issues.extend(...)` unconditionally."""
685
+ issues: list[Issue] = []
686
+ pin_issue = project_pin_issue(broken_project_pin, tier)
687
+ if pin_issue is not None:
688
+ issues.append(pin_issue)
689
+ foreign_issue = global_default_foreign_project_issue(foreign_global_default_for)
690
+ if foreign_issue is not None:
691
+ issues.append(foreign_issue)
692
+ return issues
693
+
694
+
695
+ @dataclass(frozen=True)
696
+ class SdkRootResolution:
697
+ """`resolve_sdk_root_ladder`/`resolve_sdk_root_wide`'s own return type --
698
+ a NAMED result rather than a tuple, so a fact one caller needs (e.g.
699
+ `foreign_global_default_for`, tan-cli#464) never forces every OTHER
700
+ caller's unpacking to grow a matching positional slot. A four-element
701
+ tuple was tried here first and rejected on review: it would have
702
+ reproduced the exact silent-drop shape tan-cli#464 exists to close, at
703
+ the next field this ladder needs to carry.
704
+
705
+ Mirrors [`ActiveSdk`]'s same four facts (this IS `ActiveSdk` for the
706
+ two tiers it cannot see -- `discovery`/`none` -- carried through as a
707
+ distinct type only because `.path` here stays `Path`, the shape every
708
+ filesystem-touching caller of these two ladders already expects, where
709
+ `ActiveSdk.path` is the raw `str` its own callers compare and serialize
710
+ verbatim)."""
711
+
712
+ path: Path | None
713
+ tier: str
714
+ broken_project_pin: str | None = None
715
+ foreign_global_default_for: str | None = None
716
+
717
+
718
+ def resolve_sdk_root_ladder(sdk_root_arg: str | None, workspace_root: Path) -> SdkRootResolution:
719
+ """The NARROW of this port's TWO discovery ladders,
720
+ shared by the thirteen commands the oracle resolves narrowly (`build`,
721
+ `doctor`, `clean`, `run`, `flash`, `size`, `image`, `kconfig`, `validate`,
722
+ `presets`, `inspect`, `trace`, `sdk current`). The other three -- `init`,
723
+ `generate` and `examples` -- take [`resolve_sdk_root_wide`], below.
724
+ The measurement that splits 13 from 3 is the last paragraph here, and is
725
+ deliberately not repeated there.
726
+
727
+ `--sdk-root` (terminal, I-31) > the project's own pin (`.alp/sdk-path`,
728
+ written by `tan init` / relocated by `tan bootstrap`) > the machine-global
729
+ default (`~/.alp/sdk-default`) > `resolve_sdk_tiered`'s own NARROW
730
+ discovery probe > the wide positional walk (`discover_sdk_root`, above) as
731
+ the tail -- exactly the Rust oracle's closed `SdkSourceTier`
732
+ (`crates/tan-core/src/sdk.rs`): `SdkRootFlag`, `ProjectPin`,
733
+ `GlobalDefault`, `Discovery`, `None`. No sixth tier. Both ladders report
734
+ those same five values; only the discovery tier differs between them.
735
+
736
+ This is the fix for the worst reported CX defect in this port: `tan init`
737
+ writes `.alp/sdk-path` into the project it just scaffolded, but every
738
+ command below used to jump straight from `--sdk-root` to the positional
739
+ walk, skipping `resolve_sdk_tiered` entirely -- so that pointer was
740
+ silently ignored the moment `tan build` ran in that SAME directory,
741
+ unless the checkout also happened to sit beside the project by
742
+ coincidence. `resolve_sdk_tiered` already IS the oracle's `--sdk-root` >
743
+ project pin > global default > discovery chain; the only thing missing
744
+ from it here is the WIDER positional walk as one more fallback below
745
+ `discovery`, for callers ported before `resolve_sdk_tiered` existed.
746
+
747
+ An `ALP_SDK_ROOT` env-var tier was tried here and reverted: the oracle's
748
+ `util.rs::resolve_sdk_root` only ever WRITES that variable into a build
749
+ slice's env, never reads it back for discovery, and `SdkSourceTier` has no
750
+ slot for it -- a sixth `sourceTier` value in the envelope is a wire
751
+ contract change no consumer (the vscode extension, `tan sdk current
752
+ --json`) expects. The project-pin tier above already makes `tan init &&
753
+ tan build` compose without it.
754
+
755
+ **The narrow tier short-circuiting the wide one is deliberate, and
756
+ measured** (tan-cli#263, which proposed inverting it): the wide walk puts
757
+ the CHILD `<ws>/alp-sdk` ahead of the lateral `../alp-sdk`, so hoisting it
758
+ above `resolve_sdk_tiered` would flip every workspace holding both. Driven
759
+ through the oracle binary in constructed layouts, thirteen of its commands
760
+ -- `build`, `doctor`, `clean`, `run`, `flash`, `size`, `image`, `kconfig`,
761
+ `validate`, `presets`, `inspect`, `trace`, `sdk current` -- resolve the
762
+ LATERAL one there, i.e. the narrow order this ladder already has; only
763
+ `init`, `generate` and `examples` take the child. The oracle
764
+ carries two resolutions, not one, and this ladder mirrors the majority
765
+ (narrow) one plus the wide walk as its tail. Inverting it to match the
766
+ other three would move the SDK root under thirteen commands, and a moved
767
+ root is what `plan_exec.sdk_stamp_action` reads as a switch: every existing
768
+ such workspace would take the `build.sdk-switch-pristine` branch on its
769
+ next build and lose every slice's build dir. The three wide commands were
770
+ the real gap; they now have their own helper below, which is why this one
771
+ stays exactly as it is.
772
+
773
+ `SdkRootResolution.broken_project_pin` (tan-cli#263 review) is
774
+ `ActiveSdk.broken_project_pin` carried straight through: the raw
775
+ `.alp/sdk-path` target when that file exists but its target failed the
776
+ loader check, `None` otherwise -- even when a LOWER tier (global default,
777
+ discovery, or nothing) is what this call actually returns.
778
+ [`project_pin_issue`] turns it into the shared `sdk.project-pin-unresolved`
779
+ warning; every caller here should surface it, not just `sdk current` --
780
+ `tan build` is the one that matters most, since it is the command that
781
+ silently builds against whichever SDK the pin's fallthrough landed on.
782
+ `foreign_global_default_for` (tan-cli#464) is the same carry-through for
783
+ `ActiveSdk.foreign_global_default_for`, and
784
+ [`global_default_foreign_project_issue`] is its sibling warning.
785
+ """
786
+ flag = (sdk_root_arg or "").strip()
787
+ if flag:
788
+ return SdkRootResolution(Path(flag), "sdkRootFlag")
789
+
790
+ tiered = resolve_sdk_tiered(None, workspace_root)
791
+ if tiered.path is not None:
792
+ return SdkRootResolution(
793
+ Path(tiered.path),
794
+ tiered.tier,
795
+ tiered.broken_project_pin,
796
+ tiered.foreign_global_default_for,
797
+ )
798
+
799
+ found = discover_sdk_root(workspace_root)
800
+ if found is not None:
801
+ return SdkRootResolution(found, "discovery", tiered.broken_project_pin)
802
+ return SdkRootResolution(None, "none", tiered.broken_project_pin)
803
+
804
+
805
+ def resolve_sdk_root_wide(sdk_root_arg: str | None, workspace_root: Path) -> SdkRootResolution:
806
+ """The WIDE ladder, for the three commands the oracle routes through
807
+ `util.rs`'s `resolve_sdk_root`: `init`, `generate`, `examples`.
808
+ Same [`SdkRootResolution`] shape as [`resolve_sdk_root_ladder`], same
809
+ field meanings.
810
+
811
+ Same tiers and the same five `SdkSourceTier` values as
812
+ [`resolve_sdk_root_ladder`] above -- `--sdk-root` > project pin > global
813
+ default > discovery -- with exactly one difference: the discovery tier is
814
+ the WIDE positional walk (`discover_sdk_root`) rather than
815
+ `resolve_sdk_tiered`'s narrow probe, so the CHILD `<ws>/alp-sdk` wins over
816
+ the lateral `../alp-sdk` and over an enclosing checkout. The measurement
817
+ behind which command belongs to which ladder lives in that docstring; two
818
+ copies of it would be two things to keep true.
819
+
820
+ `tan init` is why this is a second helper and not a tidy-up waiting to be
821
+ folded back in (tan-cli#263). In a workspace holding both a child checkout
822
+ and a competing sibling, the oracle pins the CHILD into `.alp/sdk-path`;
823
+ the narrow ladder pinned the sibling. That pointer then outranks discovery
824
+ for every later command in that project, so the wrong SDK is not a
825
+ one-command wobble -- it is bound permanently, by the command whose whole
826
+ job was to bind the right one.
827
+
828
+ Skipping the narrow discovery tier can never resolve LESS than the narrow
829
+ ladder would: its candidates (the workspace root itself, `../alp-sdk`, the
830
+ nearest enclosing checkout) are a subset of the wide walk's, so wherever
831
+ narrow hits, wide hits too -- it may only pick a nearer checkout.
832
+ """
833
+ flag = (sdk_root_arg or "").strip()
834
+ if flag:
835
+ return SdkRootResolution(Path(flag), "sdkRootFlag")
836
+
837
+ tiered = resolve_sdk_tiered(None, workspace_root)
838
+ if tiered.path is not None and tiered.tier != "discovery":
839
+ return SdkRootResolution(
840
+ Path(tiered.path),
841
+ tiered.tier,
842
+ tiered.broken_project_pin,
843
+ tiered.foreign_global_default_for,
844
+ )
845
+
846
+ found = discover_sdk_root(workspace_root)
847
+ if found is not None:
848
+ return SdkRootResolution(found, "discovery", tiered.broken_project_pin)
849
+ return SdkRootResolution(None, "none", tiered.broken_project_pin)
850
+
851
+
852
+ #: tan-cli#407's warning code. Namespaced `sdk.` and not `build.`/`generate.`
853
+ #: on purpose: a caller on EITHER ladder emits this identical string, so a
854
+ #: consumer holding one envelope from each side matches them on the code, and
855
+ #: a per-command spelling would make the pair it has to correlate the one
856
+ #: thing that differs.
857
+ SDK_DISCOVERY_DIVERGENT = "sdk.discovery-divergent"
858
+
859
+ #: Both command groups spelled out in the warning itself, because the reader
860
+ #: is being told their toolchain is split across two checkouts and the useful
861
+ #: next question -- "which of my commands went where?" -- must not require
862
+ #: reading this source.
863
+ _NARROW_COMMANDS = (
864
+ "build, doctor, clean, run, flash, size, image, kconfig, validate, "
865
+ "presets, inspect, trace and sdk current"
866
+ )
867
+ _WIDE_COMMANDS = "init, generate and examples"
868
+
869
+
870
+ def sdk_ladder_divergence_issue(
871
+ sdk_root_arg: str | None, workspace_root: Path, *, wide: bool
872
+ ) -> Issue | None:
873
+ """The tan-cli#407 warning for a workspace where the two ladders above
874
+ answer DIFFERENT checkouts -- `None` (the overwhelmingly common case)
875
+ when they agree, so every call site can do `issue =
876
+ sdk_ladder_divergence_issue(...); if issue: issues.append(issue)`
877
+ unconditionally, exactly like [`project_pin_issue`].
878
+
879
+ The divergence itself is deliberate and oracle-measured (see
880
+ [`resolve_sdk_root_ladder`]'s own docstring, and
881
+ `tests/commands/test_build_manifest.py`), and this does not try to remove
882
+ it. What it removes is the SILENCE: both ladders label their answer with
883
+ the same `SdkSourceTier` string, `"discovery"`, so in a workspace holding
884
+ both a child `<ws>/alp-sdk` and a lateral `../alp-sdk`, `tan generate`
885
+ emits `build/generated/alp.conf`, the DTS overlays and
886
+ `alp_hw_info_build.h` from one checkout's `metadata/` while `tan build`,
887
+ `tan size` and `tan trace` plan against the other -- and both envelopes
888
+ say `discovery`, leaving no field a consumer could compare. `sdk: {root,
889
+ sourceTier}` exists (#110) precisely so the vscode extension can tell
890
+ which SDK produced a result instead of guessing; one tier name for two
891
+ answers is that key failing at its only job.
892
+
893
+ A sixth `SdkSourceTier` value would be the other way to say this and is
894
+ rejected upstream for the same reason [`resolve_sdk_root_ladder`] rejects
895
+ an `ALP_SDK_ROOT` tier: the closed enum is a wire contract no consumer
896
+ expects to grow, and `test_build_manifest.py` pins the exact
897
+ `SdkRootResolution(path, "discovery", None, None)` both ladders return
898
+ here because that is the oracle's own answer. An `issues[]` entry adds a
899
+ fact without moving a reported one -- the same trade `broken_project_pin`
900
+ already makes.
901
+
902
+ `wide` only picks which side of the pair is labelled "this command"; both
903
+ sides name both checkouts, in the same order, so two envelopes describing
904
+ one collision read as one collision rather than two unrelated warnings.
905
+
906
+ Both roots are compared as `_abs_posix` strings rather than as `Path`s
907
+ because that is the spelling the message quotes and the spelling
908
+ `sdk.root` carries on the wire -- comparing anything else risks reporting
909
+ a "divergence" between two names for one directory. Safe here precisely
910
+ because both ladders derive every candidate from the same
911
+ `workspace_root`: the tiers ABOVE discovery (`--sdk-root`, project pin,
912
+ global default) are shared verbatim, so they can never be the pair that
913
+ differs.
914
+ """
915
+ narrow_path = resolve_sdk_root_ladder(sdk_root_arg, workspace_root).path
916
+ wide_path = resolve_sdk_root_wide(sdk_root_arg, workspace_root).path
917
+ # One side unresolved is not a collision to disambiguate: the two
918
+ # envelopes already differ by `sourceTier` (`none` vs something), which is
919
+ # the very signal this warning exists to supply.
920
+ if narrow_path is None or wide_path is None:
921
+ return None
922
+
923
+ narrow = _abs_posix(str(narrow_path))
924
+ wider = _abs_posix(str(wide_path))
925
+ if narrow == wider:
926
+ return None
927
+
928
+ # Function-scoped: see the module docstring -- `tan.envelope` imports
929
+ # THIS function at module level (that top-level import is the whole
930
+ # point of tan-cli#408), so a top-level import back would be circular.
931
+ from tan.envelope import Issue
932
+
933
+ narrow_label = _NARROW_COMMANDS if wide else "this command"
934
+ wide_label = "this command" if wide else _WIDE_COMMANDS
935
+ return Issue(
936
+ SDK_DISCOVERY_DIVERGENT,
937
+ "warning",
938
+ f"two alp-sdk checkouts resolve from this directory and both report "
939
+ f'sourceTier "discovery": {narrow_label} uses "{narrow}", while '
940
+ f'{wide_label} uses "{wider}". Generated files and the build plan can '
941
+ f"therefore come from different SDK versions -- pin the one you mean "
942
+ f"with --sdk-root, or with `tan init --sdk-root <path>` to write it "
943
+ f"into .alp/sdk-path, which outranks both discovery tiers.",
944
+ )
945
+
946
+
947
+ def _planner_python_resolution(start: str, sdk_root: str | None) -> tuple[str, bool]:
948
+ """Like [`_planner_python`], but also reports whether it resolved a
949
+ `tan bootstrap` workspace venv (`True`) or fell back to a bare PATH name
950
+ (`False`).
951
+
952
+ tan-cli#652: `validate_cmd`/`diff_cmd` need this flag to tell "this
953
+ interpreter is missing a dependency because no workspace venv exists yet
954
+ -- run `tan bootstrap`" from "this interpreter is missing a dependency
955
+ for some other reason" when a spawned SDK script dies importing one. A
956
+ bare fallback interpreter that HAPPENS to already have the SDK's
957
+ dependencies (the common from-source-install shape: `jsonschema` is also
958
+ one of tan's own declared dependencies, so it lands on the same
959
+ interpreter `pip install` used) must not be refused pre-emptively --
960
+ only a run that actually fails gets the remedy attached, and only when
961
+ this flag says there was no venv to blame instead.
962
+ """
963
+ resolved = venv_python(start, sdk_root)
964
+ fallback = "python" if os.name == "nt" else "python3"
965
+ return resolved or fallback, resolved is not None
966
+
967
+
968
+ def _planner_python(start: str, sdk_root: str | None) -> str:
969
+ """The interpreter a SPAWNED build step runs under.
970
+
971
+ Two callers remain now that planning and every `generate` target run
972
+ in-process: `${PYTHON}` token substitution (below), and
973
+ `generate_cmd`'s `TAN_GENERATE_EXECUTOR=subprocess` escape hatch.
974
+
975
+ Prefers the west-capable workspace venv's `python`
976
+ (`tan.core.venv.venv_python`, mirroring Rust's `venv_python`/
977
+ `resolved_planner_python`), because the SDK planner bakes ITS
978
+ `sys.executable` into every Zephyr slice as `-DPython3_EXECUTABLE=<...>`
979
+ (alp-sdk#787) -- a bare PATH `python3` may lack the `west` module
980
+ entirely, which surfaced as the planner's own `ModuleNotFoundError: No
981
+ module named 'west'` inside CMake configure (tan-cli, the
982
+ documented-install-path bug) rather than a working build.
983
+
984
+ Falls back to a bare PATH name -- `python3` off Windows, `python` on it,
985
+ mirroring `tan_core::project::resolve_python_binary` -- only when no
986
+ workspace venv resolves. NOT `sys.executable`: frozen by PyInstaller,
987
+ `sys.executable` is `tan` itself, so spawning it would just re-enter this
988
+ CLI; and this value is also the `${PYTHON}` substituted into the plan, so
989
+ it has to name an interpreter the slice can find, not this process.
990
+
991
+ A thin wrapper over [`_planner_python_resolution`] -- kept as its own
992
+ name because every existing caller wants only the interpreter path, not
993
+ the venv flag, and a second copy of the resolution logic is exactly the
994
+ kind of drift this repo's conventions warn against.
995
+ """
996
+ return _planner_python_resolution(start, sdk_root)[0]