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,2236 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan build` -- the one command that turns a build plan into firmware.
3
+
4
+ Composition, not logic: this module resolves the project, ACQUIRES a plan (from
5
+ `--plan-from`, else by running the planner in-process -- there is no other way any
6
+ more; see `_emit_plan`), then hands it in turn to the four sub-project-1
7
+ libraries -- `parse_build_plan`, `apply_plan_token_substitution`,
8
+ `materialise_plan`, `execute_slices` -- and
9
+ folds the result into one envelope. Every decision those modules own stays
10
+ theirs; what lives here is the ORDER they run in, the mapping from their
11
+ `(code, message)` failures to an exit code, and the guarantee that not one of
12
+ them can escape as a traceback.
13
+
14
+ Three properties this file exists to hold:
15
+
16
+ **Order is the contract (I-20).** `materialise_plan` writes every
17
+ `sharedArtefacts` entry AND every slice's `configArtefacts` before
18
+ `execute_slices` is called at all. The plan carries no `sequential` flag and
19
+ no `inputHash`; the only thing making its slices independent is that all their
20
+ inputs are on disk before the first one starts. `tests/commands/
21
+ test_build_command.py` proves it by making the first slice's own command
22
+ assert the OTHER slice's config artefact already exists.
23
+
24
+ **Nothing but the envelope on stdout.** Slice output streams to stderr (see
25
+ `_stream`), the text-mode recap goes to stderr, and the JSON envelope is the
26
+ only thing ever written to stdout. The extension parses stdout whole; one
27
+ stray byte and it renders nothing, with no error on either side.
28
+
29
+ **`--plan-from` is a plan SOURCE, not a build trigger (v0.4.1 §gate).** The
30
+ oracle's `--plan-from` help says "Implies `--plan`", and measured against the
31
+ shipped `tan 0.4.1-dev` binary in fresh temp dirs, on
32
+ `tests/parity/oracle/multicore_rpmsg-aen.build-plan.json`, it means it -- even
33
+ against an EXPLICIT `--native`::
34
+
35
+ build --plan-from p.json -> rc 0, 0 files, data = the plan
36
+ build --plan-from p.json --native -> rc 0, 0 files, data = the plan
37
+ build --plan-from p.json --materialise -> rc 0, 6 files,
38
+ data = {schemaVersion, baseDir, written}
39
+
40
+ All three are reproduced here exactly (`_MODE_PLAN` / `_MODE_MATERIALISE`),
41
+ `--native` included. Two earlier revisions of this file each redefined one of
42
+ those argvs -- a bare `--plan-from` used to materialise AND dispatch, and then
43
+ `--plan-from --native` did -- so a v0.4.1 script that only ever INSPECTED a
44
+ plan silently began writing six files into its own tree. Changing what a
45
+ v0.4.1 argv MEANS is the whole surprise this gate exists to remove.
46
+
47
+ **`--execute` is a deliberate PORT ADDITION the oracle has no flag for.**
48
+ Taking a pinned, reviewed plan file and running it reproducibly is a normal
49
+ hermetic-CI request; v0.4.1's inability to do it is a LIMITATION of that CLI,
50
+ not a contract worth preserving -- there `--plan-from`'s implied `--plan`
51
+ outranks `--native`, so a file-supplied plan cannot be dispatched at all.
52
+ `--execute` supplies exactly that capability without changing what any v0.4.1
53
+ argv means. It is not a parity bug and not a test hook: a future reader who
54
+ "fixes" it by deleting it removes a supported capability. Its `data` is the
55
+ ORDINARY build shape -- `{schemaVersion, baseDir, slices[], warnings[]}` --
56
+ because it IS a native build and only the plan's SOURCE differs; `written` is
57
+ deliberately omitted, being byte-for-byte the pinned plan's own declared
58
+ artefact paths, which the one caller who has that file already holds. A
59
+ conflicting combination is refused with its own code, never resolved by silent
60
+ precedence (`--execute --materialise` -> `build.conflicting-flags`, exit 2;
61
+ `--pristine` with a mode that never dispatches (bare `--plan-from` or
62
+ `--materialise`, neither paired with `--execute`) -> the same
63
+ `build.conflicting-flags`, exit 2, because a wipe promised on the envelope
64
+ cannot be silently skipped either; `--execute` with a RETIRED flag ->
65
+ `build.flag-retired`, exit 2, because a flag this build refuses outright
66
+ cannot be honoured either way).
67
+ `--execute` is ADDED BY THIS PORT, not a v0.4.1 flag: there `--plan-from`
68
+ implies `--plan` and outranks `--native`, so a file-supplied plan cannot be
69
+ dispatched at all. Deliberate, not a parity gap. `--native` keeps v0.4.1's
70
+ behaviour of NOT overriding the `--plan` that `--plan-from` implies, which
71
+ `test_plan_from_shows_the_plan_and_writes_nothing_even_with_native` pins.
72
+
73
+ **The OS is not an option (I-01/I-02).** There is deliberately no `--os` and
74
+ no `--backend` flag. A core's runtime is derived from its Cortex class by the
75
+ planner (`tan/planner/topology.py`), and `slices[].backend`
76
+ arrives already resolved. Nor does this command filter the plan's slices: a
77
+ one-core `board.yaml` legitimately plans three (I-04), and "helpfully"
78
+ dropping the ones the customer did not name is how a Yocto slice silently
79
+ stops being built.
80
+ """
81
+ import json
82
+ import os
83
+ import sys
84
+ import threading
85
+ import time
86
+ from dataclasses import dataclass, replace
87
+ from pathlib import Path
88
+
89
+ import typer
90
+
91
+ from tan.commands.build.execute import (
92
+ KNOWN_BACKENDS,
93
+ SliceOutcome,
94
+ execute_slices,
95
+ last_configure_cache_issues,
96
+ last_manifest_write_failure,
97
+ last_sdk_switch_issues,
98
+ reset_last_manifest_write,
99
+ )
100
+ from tan.commands.build.materialise import MaterialiseError, materialise_plan
101
+ from tan.commands.build.token_substitution import (
102
+ TokenSubstitutionError,
103
+ apply_plan_token_substitution,
104
+ deferred_placeholder_issues,
105
+ )
106
+ from tan.commands.build.workspace_patches import workspace_patch_issues
107
+ from tan.commands.build.toolchain import ToolchainResolution, resolve_toolchain_root
108
+ from tan.core.user_defines import UserDefineError, apply_user_defines, define_pairs, user_defines_problem
109
+ from tan.core.board_files import all_topology_boards, unmatched_board_file_messages
110
+ from tan.core.plain_zephyr_plan import board_target_problem, normalise_defines, plain_zephyr_plan
111
+ from tan.core.build_plan import BuildPlan, PlanParseError, parse_build_plan
112
+ from tan.core.global_flags import accept_global_flags
113
+ from tan.core.link_refusal import refusal_code
114
+ from tan.core.plan_exec import (
115
+ CROSS_DRIVE_MSG,
116
+ MISSING_TOOL_RE,
117
+ PolicyAction,
118
+ normalize_path,
119
+ resolve_action,
120
+ )
121
+ from tan.core.west_workspace_refusal import WORKSPACE_UNRESOLVED_MSG
122
+ from tan.commands.build.host_python import refusal_message, resolve_build_python
123
+ from tan.core.plan_tokens import TOKEN_TOOLCHAIN_ROOT, DeferredPlaceholder
124
+ from tan.core.sdk_discovery import (
125
+ _abs_posix,
126
+ global_default_foreign_project_issue,
127
+ project_pin_issue,
128
+ resolve_sdk_root_ladder,
129
+ sdk_ladder_divergence_issue,
130
+ with_sdk_search,
131
+ )
132
+ from tan.core.shapes import SDK_MARKER, is_sdk_root, rejected_sdk_root_message
133
+ from tan.env import stderr_is_tty, terminal_width
134
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
135
+ from tan.exit_codes import ExitCode
136
+ from tan.output_format import FORMAT_HELP, OutputFormat
137
+
138
+ #: `scripts/alp_project.py` is THE marker for an alp-sdk checkout -- the same
139
+ #: literal `tan_core::project` hardcodes (I-31). Renaming or relocating it
140
+ #: breaks every `--sdk-root` resolution in the CLI, so it is spelled once --
141
+ #: in `tan.core.shapes` since tan-cli#408. The import below re-exports it
142
+ #: here because a dozen modules already reach it through this one.
143
+
144
+ #: Envelope `data.slices[].status`, from `SliceOutcome.status`. The wire
145
+ #: vocabulary is the Rust one (`ok` / `skipped` / `failed`), NOT the executor's
146
+ #: internal spelling -- `succeeded` on the wire would break a consumer written
147
+ #: against the shipped binary. `cancelled` is unreachable from this command
148
+ #: (nothing here supplies a cancellation source) but maps to `failed` rather
149
+ #: than falling through to a KeyError if one is ever wired up.
150
+ _WIRE_STATUS = {"succeeded": "ok", "skipped": "skipped"}
151
+
152
+ #: What this run does with the plan it acquires -- the oracle's own precedence,
153
+ #: measured flag-combination by flag-combination against `target/debug/tan.exe`
154
+ #: on the AEN fixture: `--materialise` outranks everything (`--plan-from
155
+ #: --materialise --native` -> six files, `written` shape), the `--plan` implied
156
+ #: by `--plan-from` outranks even an explicit `--native` (`--plan-from
157
+ #: --native` -> the plan, zero files), and only a run naming NEITHER plan-mode
158
+ #: flag builds. `_MODE_NATIVE` is therefore both the default and what the
159
+ #: port-added `--execute` selects -- deliberately the SAME mode, so `--execute`
160
+ #: reports the same `data` shape a plain `tan build` does rather than a fourth
161
+ #: one no consumer is written for.
162
+ _MODE_PLAN = "plan"
163
+ _MODE_MATERIALISE = "materialise"
164
+ _MODE_NATIVE = "native"
165
+
166
+ #: The ONE top-level directory name a plan's `buildRoot` may name (tan-cli#566),
167
+ #: `tan_core::build_plan::CONSUMER_BUILD_ROOT` in the oracle. Duplicated from
168
+ #: `tan.commands.build.execute._CONSUMER_BUILD_ROOT` rather than imported, for
169
+ #: the reason that module's own copy already records: it is the SDK build-plan
170
+ #: schema's constant, not a value derived from anything in either module, and
171
+ #: the two uses are unrelated (that one gates the sdk-switch-pristine wipe on a
172
+ #: slice's plan-supplied `cwd`; this one refuses the whole plan). Importing a
173
+ #: neighbour's underscore-private name to save four characters would couple
174
+ #: this refusal to that guard's internals.
175
+ CONSUMER_BUILD_ROOT = "build"
176
+
177
+ #: tan-cli#427 resolved every flag `tan build` used to lump into one
178
+ #: refuse-everything tuple, ONE of three ways:
179
+ #:
180
+ #: 1. **Implemented for real** -- `--pristine` (see
181
+ #: `_maybe_pristine_stale_sdk_build_dir`'s `force_pristine`) and the
182
+ #: `--target`/`--all`/`--verbose`/`--quiet`/`--no-color`/
183
+ #: `--non-interactive`/`--ci` septet, which the oracle's own `cli.rs`
184
+ #: declares ONLY on the shared `GlobalArgs` struct (`BuildArgs` itself has
185
+ #: no fields for any of the seven) -- `build`'s Rust handler never reads
186
+ #: them either, so accept-and-drop via `accept_global_flags` (see the
187
+ #: bottom of this module) is not a narrower stand-in, it is the SAME
188
+ #: oracle behaviour every other command already gets from that decorator.
189
+ #: This also settles the "does `--target`/`--all` on `build` mean
190
+ #: `generate`'s emit-backend selection" question the issue raised: it does
191
+ #: not mean anything on either binary -- `generate`'s own handler is the
192
+ #: only one that ever reads the global field, so build and generate
193
+ #: sharing the spelling was never a meaning collision to resolve.
194
+ #: 2. **Retired as superseded** -- `--plan`, `--manifest`, `--manifest-from`:
195
+ #: see `_RETIRED_FLAGS` below. Still declared (never a Click parse error),
196
+ #: but refused with `build.flag-retired`, which names the replacement in
197
+ #: the message itself.
198
+ #: 3. **Nothing is left deferred.** `--no-auto-bootstrap` was the last one,
199
+ #: and it is now retired (bucket 2) rather than implemented, on a
200
+ #: maintainer decision closing tan-cli#427: `tan build` does not run
201
+ #: `tan bootstrap` implicitly and is not going to. Measured before
202
+ #: deciding -- no call path from `build` or `run` reaches bootstrap;
203
+ #: an unbootstrapped workspace surfaces as `build.plan-unavailable`
204
+ #: (`_emit_plan`'s `SystemExit` catch), never an automatic invocation.
205
+ #: So there was never a trigger for the flag to disable, and building one
206
+ #: just to give the flag something to switch off would add a heavy
207
+ #: implicit side effect (a `west update` and a venv install) to a command
208
+ #: whose job is to build. A flag that disables a behaviour this CLI does
209
+ #: not have is not deferred work -- it is a flag with nothing to mean, so
210
+ #: it teaches the explicit command instead.
211
+
212
+ #: The oracle spelling -> its replacement flag, for the three flags
213
+ #: `_refuse_retired` reports (tan-cli#427): "Two overlapping plan surfaces is
214
+ #: worse than one, so the oracle spellings go" (the issue's own maintainer
215
+ #: decision), but a retired flag's error must TEACH the replacement, not just
216
+ #: refuse -- a customer who typed `--plan` is told to type `--plan-from`, in
217
+ #: the message itself, never pointed at a tracker to work it out.
218
+ #:
219
+ #: `--plan` (bare, live-fetched) has no direct one-flag substitute -- `
220
+ #: --plan-from FILE` covers the file-sourced half of what it did, with
221
+ #: `--materialise`/`--execute` covering the act-on-it half; there is no
222
+ #: "preview the live plan and stop" mode left, which is the trade the
223
+ #: decision accepts in exchange for one plan surface instead of two.
224
+ #: `--manifest`/`--manifest-from` showed `build/system-manifest.yaml`
225
+ #: (a DIFFERENT artefact from the build plan) before or without a real
226
+ #: build; a native `tan build` already writes that exact file, and it is
227
+ #: plain YAML a caller can read directly -- no second command needed.
228
+ _RETIRED_FLAGS: dict[str, str] = {
229
+ "--plan": (
230
+ "`--plan` is retired: run `tan build --plan-from FILE` to inspect a build plan "
231
+ "instead (add `--materialise` or `--execute` to act on it)."
232
+ ),
233
+ "--manifest": (
234
+ "`--manifest` is retired: a native `tan build` already writes "
235
+ "`build/system-manifest.yaml` for you to read directly -- no separate flag needed."
236
+ ),
237
+ "--manifest-from": (
238
+ "`--manifest-from FILE` is retired: `system-manifest.yaml` is plain YAML -- open "
239
+ "FILE directly instead of routing it through `tan build`."
240
+ ),
241
+ # Retired rather than implemented (tan-cli#427): there is no implicit
242
+ # bootstrap for it to switch off, and there is not going to be one, so the
243
+ # message teaches the explicit command rather than describing a toggle.
244
+ "--no-auto-bootstrap": (
245
+ "`--no-auto-bootstrap` is retired: `tan build` never bootstraps implicitly, so "
246
+ "there is nothing for it to disable -- run `tan bootstrap` yourself when a "
247
+ "workspace needs preparing."
248
+ ),
249
+ }
250
+
251
+
252
+ class BuildError(Exception):
253
+ """A build failure with its issue code and exit code already decided --
254
+ the shape every step in `_build` reports through, so the caller never has
255
+ to guess a code from an exception type."""
256
+
257
+ def __init__(self, code: str, message: str, exit_code: ExitCode) -> None:
258
+ super().__init__(message)
259
+ self.code = code
260
+ self.message = message
261
+ self.exit_code = exit_code
262
+
263
+
264
+ def _stream(line: str) -> None:
265
+ """Slice output goes to stderr -- ALWAYS, both formats. stdout is the
266
+ envelope channel and carries nothing else; stderr carries no contract at
267
+ all, so there is nothing to keep separate between the two modes."""
268
+ print(line, file=sys.stderr, flush=True)
269
+
270
+
271
+ #: Idle time -- since the last line ANY slice printed, or since dispatch
272
+ #: began if none has arrived yet -- before [`_Heartbeat`] starts announcing
273
+ #: it is still alive. Measured against tan-cli#287: a first-build CMake
274
+ #: configure sat fully silent for 234.2s; a warm-cache rebuild (21.1s) prints
275
+ #: output throughout and never crosses this. Short enough to catch the
276
+ #: former, long enough that ordinary tool-startup chatter never trips it.
277
+ _HEARTBEAT_SILENCE_THRESHOLD_S = 5.0
278
+ #: Refresh cadence once armed -- a live, in-place counter (`\r`, no
279
+ #: trailing newline), not a growing log.
280
+ _HEARTBEAT_TICK_S = 1.0
281
+ #: `tan.env.terminal_width`'s fallback when stderr's column count can't be
282
+ #: read at all. Should not be reachable -- `_Heartbeat` is only ever armed
283
+ #: behind an `isatty()` gate -- but naming a value here is still cheaper
284
+ #: than trusting that gate never has a gap.
285
+ _HEARTBEAT_WIDTH_FALLBACK = 80
286
+
287
+
288
+ def _heartbeat_line_width() -> int:
289
+ """Usable columns for one self-overwriting heartbeat line: the
290
+ terminal's own width, minus one.
291
+
292
+ tan-cli#287 blocker: the erase pad used to be a HARDCODED 88 columns
293
+ while the message it erased ran 114+ chars (more once elapsed hits
294
+ 1000s). On a terminal narrower than 114 columns -- 80 is the common
295
+ case -- `\r` rewinds only the terminal's OWN last row, so a message
296
+ that wraps grows a fresh row every tick instead of overwriting one:
297
+ ~234 stacked "still building" rows over the measured 234.2s configure.
298
+ Sizing the message AND its erase pad off the actual terminal width,
299
+ every tick, removes the mismatch instead of guessing a bigger constant.
300
+ The `- 1` leaves the last column unwritten: filling a line to the exact
301
+ terminal width is what triggers autowrap on many terminals, which would
302
+ make the fix for the wrap defect one more way to trigger it.
303
+
304
+ tan-cli#564: measured through [`terminal_width`], which reads stderr's own
305
+ fd -- the handle the heartbeat is armed on and writes to. The previous
306
+ `shutil.get_terminal_size` call resolved `sys.__stdout__` instead, so
307
+ `tan build > log.txt` on that same 70-column terminal returned 79 rather
308
+ than 69 and `_tick`'s `message.ljust(width)` wrote a 79-char row onto a
309
+ 70-column screen: it soft-wraps, `\r` rewinds only the last physical row,
310
+ and the "still building" line stacks a fresh row per tick -- exactly the
311
+ tan-cli#287 defect this width computation was added to remove.
312
+
313
+ # ponytail: re-reads the width every tick rather than caching it, so a
314
+ # terminal resized mid-build can leave a stale tick's tail past the
315
+ # newly narrower edge; not the reported defect (a STATIC undersized
316
+ # pad on an unchanged terminal) and not measured here -- revisit if a
317
+ # resize-mid-build report ever lands.
318
+ """
319
+ columns = terminal_width(_HEARTBEAT_WIDTH_FALLBACK)
320
+ return max(columns - 1, 1)
321
+
322
+
323
+ class _Heartbeat:
324
+ """The `on_output` callback `_dispatch` constructs and hands
325
+ `execute_slices` directly: relays every line to [`_stream`] like
326
+ before, and -- TTY + text-mode only -- prints a single self-overwriting
327
+ "still building" line once the child has gone quiet for
328
+ `_HEARTBEAT_SILENCE_THRESHOLD_S` (tan-cli#287: a four-minute silent
329
+ CMake configure read as a hang with nothing to say otherwise). The
330
+ instant real output resumes, the line is blanked so it never mixes
331
+ with the stream it was standing in for.
332
+
333
+ Armed by [`_dispatch`] itself, not handed down by its caller: `_build`'s
334
+ two callers -- `build_cmd.build` and `run_cmd._run` -- both
335
+ bottleneck through `_dispatch` before any slice runs, so constructing
336
+ the heartbeat there covers `tan run`'s identical silent-CMake window
337
+ too, not just a direct `tan build` (tan-cli#287 follow-up).
338
+
339
+ `execute_slices` calls `on_output` once per line and has no hook for
340
+ "a slice started" or "configure ended, compile began" -- adding one
341
+ would mean editing `tan.commands.build.execute`, outside this file's
342
+ scope -- so this cannot name PHASES, only measure silence across the
343
+ whole dispatch and say tan is still alive.
344
+
345
+ # ponytail: one wall-clock timer for the whole dispatch, not a per-slice
346
+ # or per-phase one -- upgrade to a per-slice "building <core>" line if
347
+ # `execute_slices` ever grows a slice-start hook.
348
+
349
+ A disabled instance (`enabled=False` -- not a TTY, or `--format json`)
350
+ still relays every line via `__call__`, but never starts its thread and
351
+ so never prints anything of its own: behaviourally identical to passing
352
+ `_stream` straight through."""
353
+
354
+ def __init__(self, enabled: bool) -> None:
355
+ self._enabled = enabled
356
+ self._start = time.monotonic()
357
+ self._last_output = self._start
358
+ self._lock = threading.Lock()
359
+ self._stop = threading.Event()
360
+ self._thread: threading.Thread | None = None
361
+ self._line_up = False
362
+
363
+ def __call__(self, line: str) -> None:
364
+ # Clear any live heartbeat line FIRST -- `_stream`'s `print` commits
365
+ # a trailing newline, so blanking AFTER it would scroll the
366
+ # heartbeat text away merged onto the same line as real output
367
+ # instead of erasing it.
368
+ self.note_output()
369
+ _stream(line)
370
+
371
+ def note_output(self) -> None:
372
+ with self._lock:
373
+ self._last_output = time.monotonic()
374
+ self._clear_locked()
375
+
376
+ def _clear_locked(self) -> None:
377
+ if self._line_up:
378
+ width = _heartbeat_line_width()
379
+ print("\r" + " " * width + "\r", end="", file=sys.stderr, flush=True)
380
+ self._line_up = False
381
+
382
+ def _tick(self) -> None:
383
+ while not self._stop.wait(_HEARTBEAT_TICK_S):
384
+ with self._lock:
385
+ idle = time.monotonic() - self._last_output
386
+ if idle < _HEARTBEAT_SILENCE_THRESHOLD_S:
387
+ continue
388
+ elapsed = time.monotonic() - self._start
389
+ width = _heartbeat_line_width()
390
+ # Truncated AND right-padded to `width`, both to the SAME
391
+ # value: truncation keeps this tick's own text off the
392
+ # terminal's last column (see `_heartbeat_line_width`), and
393
+ # the pad overwrites a longer PRIOR tick's tail (elapsed/
394
+ # idle growing from single to double digits, say) -- both
395
+ # ticks compute the same width, so the pad always covers it
396
+ # on an unchanged terminal.
397
+ message = (
398
+ f"... still building ({elapsed:.0f}s elapsed, no output for "
399
+ f"{idle:.0f}s -- CMake configure can take several minutes on a "
400
+ "first build)"
401
+ )[:width]
402
+ print("\r" + message.ljust(width), end="", file=sys.stderr, flush=True)
403
+ self._line_up = True
404
+
405
+ def __enter__(self) -> "_Heartbeat":
406
+ if self._enabled:
407
+ self._thread = threading.Thread(target=self._tick, daemon=True)
408
+ self._thread.start()
409
+ return self
410
+
411
+ def __exit__(self, *exc_info: object) -> None:
412
+ self._stop.set()
413
+ if self._thread is not None:
414
+ self._thread.join(timeout=_HEARTBEAT_TICK_S * 2)
415
+ with self._lock:
416
+ self._clear_locked()
417
+
418
+
419
+ def _emit_plan(sdk_root: str | None, board_yaml: str | None) -> str:
420
+ """Plan `board_yaml` against the SDK at `sdk_root`.
421
+
422
+ IN-PROCESS, always. `tan.planner_root.emit` binds the SDK root -- which is
423
+ where `metadata/**` and the fact-reader modules still live (ADR-0017) -- and
424
+ renders through the same `emit_artefact` dispatch `python -m tan.planner`
425
+ uses. No interpreter on PATH, no PYTHONPATH, and nothing under
426
+ `<sdk>/scripts` imported or spawned -- but NOT "no process" full stop:
427
+ `emit_build_plan` still spawns a short-lived `git rev-parse` (a stamped
428
+ `sdkCommit`, `buildplan.py:339-372`) whenever `git` resolves on PATH; with
429
+ none, it returns `None` first and the plan carries `sdkCommit: null`.
430
+
431
+ **The `<python> -m alp_orchestrate --emit build-plan` fallback was RETIRED
432
+ here, deliberately.** It fired when this build of `tan` could not import the
433
+ planner and quietly planned through the SDK's own copy instead -- and it
434
+ reported nothing at all when it did. Three reasons it had to go, any one
435
+ sufficient:
436
+
437
+ * It was SILENT. A run that fell back looked exactly like a run that did
438
+ not, which is the same defect class that let the planner relocation be
439
+ believed before it happened.
440
+ * It could only fire on a broken install. `jsonschema` and `PyYAML` are
441
+ declared `dependencies` in `pyproject.toml` and installed into the clean
442
+ venv `scripts/build_binary.sh` freezes, so the trigger is not a supported
443
+ layout -- and a missing dependency deserves `pip install jsonschema`, not
444
+ a different planner.
445
+ * It kept alp-sdk's `scripts/alp_orchestrate/` load-bearing forever. As long
446
+ as any tan path can reach it, it can never be deleted, and deleting it is
447
+ the point.
448
+
449
+ What replaces it is a coded refusal naming the missing dependency -- which is
450
+ strictly more actionable than a silent success against a second planner whose
451
+ version nothing checks.
452
+ """
453
+ if sdk_root is None:
454
+ raise BuildError(
455
+ "build.plan-unavailable",
456
+ "no alp-sdk checkout found -- pass `--sdk-root <PATH>` or run from a project "
457
+ "beside one. Planning reads the SDK's `metadata/**`. Run `tan doctor` to see "
458
+ "which checkout tan resolves, if any.",
459
+ ExitCode.RUNTIME_FAILURE,
460
+ )
461
+ if board_yaml is None:
462
+ raise BuildError(
463
+ "build.plan-unavailable",
464
+ "no board.yaml found -- pass `--board-yaml <PATH>` or run from a project; "
465
+ "for a plain Zephyr example without a board.yaml (alp-sdk's bench examples), "
466
+ "pass `--board <zephyr-board-target>` instead. "
467
+ "Run `tan init` to create a project, or `tan examples` to list ready-made ones.",
468
+ ExitCode.RUNTIME_FAILURE,
469
+ )
470
+ # `--sdk-root` is terminal and returned as-is even when wrong (I-31), so the
471
+ # first thing planning reads is checked here rather than surfacing as a
472
+ # loader stack trace. The board schema, not the planner: the planner is ours
473
+ # now, and requiring the SDK to still carry `alp_orchestrate` would refuse
474
+ # precisely the releases this relocation exists to enable.
475
+ if not (Path(sdk_root) / "metadata" / "schemas" / "board.schema.json").is_file():
476
+ raise BuildError(
477
+ "build.plan-unavailable",
478
+ f"the SDK at `{sdk_root}` has no `metadata/schemas/board.schema.json` -- "
479
+ f"that path is not an alp-sdk checkout, or the checkout is incomplete.",
480
+ ExitCode.RUNTIME_FAILURE,
481
+ )
482
+
483
+ try:
484
+ from tan.planner_root import emit as _plan_emit
485
+ except ImportError as err: # pragma: no cover -- planner absent from this build
486
+ raise BuildError(
487
+ "build.plan-unavailable",
488
+ f"this build of `tan` cannot import its own planner ({err}). "
489
+ "Reinstall it, or install the planner's dependencies "
490
+ "(`pip install pyyaml jsonschema`).",
491
+ ExitCode.RUNTIME_FAILURE,
492
+ ) from err
493
+ try:
494
+ return _plan_emit("build-plan", root=sdk_root, board_yaml=Path(board_yaml))
495
+ except ImportError as err:
496
+ # A planner dependency (`jsonschema`, `PyYAML`) is missing from THIS
497
+ # build. Named, not worked around: see the docstring on why the
498
+ # spawn-the-SDK's-planner fallback was retired.
499
+ raise BuildError(
500
+ "build.plan-unavailable",
501
+ f"the planner could not be imported ({err}) -- install its "
502
+ "dependencies (`pip install pyyaml jsonschema`) or reinstall `tan`.",
503
+ ExitCode.RUNTIME_FAILURE,
504
+ ) from err
505
+ except SystemExit as err:
506
+ # `tan.planner.loader` / `kconfig_symbols` call `sys.exit()` on a missing
507
+ # dependency or an unbootstrapped Zephyr workspace. In a subprocess that
508
+ # was a non-zero rc; in-process it would tear down the whole CLI past
509
+ # every envelope guarantee.
510
+ raise BuildError(
511
+ "build.plan-unavailable",
512
+ f"the build-plan emit exited early (code {err.code}).",
513
+ ExitCode.RUNTIME_FAILURE,
514
+ ) from err
515
+ except Exception as err:
516
+ # `diagnostics.link: itcm` refused (tan-cli#1350): a board.yaml input
517
+ # error with its own code, not a planner fault. Recognised by its code
518
+ # (`refusal_code`), not its class: the planner needs a bound SDK root
519
+ # to import.
520
+ if refusal_code(err) is not None:
521
+ raise BuildError(err.code, str(err), ExitCode.VALIDATION_FAILURE) from err
522
+ # Every planner failure is an envelope, never a traceback -- the same
523
+ # thing the subprocess boundary used to buy for free. Includes the bare
524
+ # `ValueError`s the Ethos-U sizing path still raises (I-48).
525
+ raise BuildError(
526
+ "build.plan-unavailable",
527
+ f"the build-plan emit failed: {type(err).__name__}: {err}",
528
+ ExitCode.RUNTIME_FAILURE,
529
+ ) from err
530
+
531
+
532
+ def _acquire_plan(
533
+ plan_from: str | None, sdk_root: str | None, board_yaml: str | None
534
+ ) -> tuple[str, BuildPlan]:
535
+ """`(source text, parsed plan)`. The TEXT comes back too because
536
+ `_MODE_PLAN` echoes it: the oracle's plan output is a raw passthrough, not
537
+ a round-trip through its own structs -- measured by adding an unknown
538
+ top-level key to the fixture, which survives verbatim into `data`. Echoing
539
+ a re-serialised `BuildPlan` would silently drop exactly the forward-compat
540
+ keys a newer SDK adds, which is the drift the version-skew guard exists to
541
+ make loud."""
542
+ if plan_from is not None:
543
+ try:
544
+ text = Path(plan_from).read_text(encoding="utf-8")
545
+ except (OSError, UnicodeDecodeError) as err:
546
+ # UnicodeDecodeError is a ValueError, NOT an OSError, so `except
547
+ # OSError` alone let it fall through to the catch-all and report a
548
+ # bad INPUT as a tan bug (`build.internal-failure`, exit 5).
549
+ # Reachable on the exact flow `--plan-from` exists for: `tan build
550
+ # --plan --format json > plan.json` in Windows PowerShell 5.1
551
+ # writes UTF-16LE, and this is the replay half of that
552
+ # capture-then-replay loop.
553
+ raise BuildError(
554
+ "build.plan-unavailable",
555
+ f"failed to read plan file `{plan_from}`: {err}",
556
+ ExitCode.RUNTIME_FAILURE,
557
+ ) from err
558
+ else:
559
+ text = _emit_plan(sdk_root, board_yaml)
560
+
561
+ try:
562
+ return text, parse_build_plan(text)
563
+ except PlanParseError as err:
564
+ # Exit 1, NOT 2. A malformed plan reads like a validation failure and
565
+ # the semantic pull toward `ValidationFailure` is real -- but the
566
+ # shipped binary reports RuntimeFailure here
567
+ # (`crates/tan-cli/src/commands/build/plan_modes.rs:140-146` and
568
+ # `native.rs:110-116` both call `plan_error_run(..., "build.plan-
569
+ # invalid", ..., ExitCode::RuntimeFailure)`), and the exit ladder is a
570
+ # frozen contract. The consumer makes it worse than cosmetic:
571
+ # `alp-sdk-vscode/src/alpCli/service.ts:253-259` renders exit 2 as
572
+ # `severity: "warning"` and exit 1 as `"error"`, so "2" would downgrade
573
+ # a plan that will not parse -- a hard failure -- to a yellow banner.
574
+ # 2 stays for a genuine validation failure in Rust's own terms, e.g.
575
+ # `validate`'s schema violation, whose fixture pins exit 2.
576
+ raise BuildError(err.code, err.message, ExitCode.RUNTIME_FAILURE) from err
577
+
578
+
579
+ def _slice_result(core_id: str, backend: str, outcome: SliceOutcome) -> dict:
580
+ """One `data.slices[]` entry. `rc`/`reason` are OMITTED when absent, not
581
+ null -- Rust's `skip_serializing_if = "Option::is_none"`.
582
+
583
+ `resolvedTool` is the ONE exception to that rule (tan-cli#510 review,
584
+ MAJOR 1): a NEW, additive field, ALWAYS present -- `null` rather than
585
+ omitted when [`SliceOutcome.resolved_tool`] is `None` -- because unlike
586
+ `rc`/`reason` it has no Rust oracle counterpart to mirror
587
+ `skip_serializing_if` against; a consumer that wants to know which
588
+ binary actually ran should not need to distinguish "the key is absent"
589
+ from "nothing to report" for a field this port alone defines. Never
590
+ folded into `reason`: that field is also the persisted
591
+ `system-manifest.yaml` `slices[].reason` (`_write_manifest_after_dispatch`
592
+ in `build/execute.py`), whose alp-sdk-owned schema defines it as "why a
593
+ slice was skipped/failed" -- a resolved path is neither."""
594
+ result = {
595
+ "coreId": core_id,
596
+ "backend": backend,
597
+ "status": _WIRE_STATUS.get(outcome.status, "failed"),
598
+ "resolvedTool": outcome.resolved_tool,
599
+ }
600
+ if outcome.exit_code is not None:
601
+ result["rc"] = outcome.exit_code
602
+ if outcome.message is not None:
603
+ result["reason"] = outcome.message
604
+ return result
605
+
606
+
607
+ def _dispatch(
608
+ plan: BuildPlan,
609
+ demotions,
610
+ build_root: Path,
611
+ sdk_root: str | None,
612
+ sdk_root_for_stamp: str | None,
613
+ *,
614
+ json_mode: bool = False,
615
+ pristine: bool = False,
616
+ slice_refusals: dict[str, str] | None = None,
617
+ user_defines: dict[str, list[str]] | None = None,
618
+ ) -> tuple[list[SliceOutcome], list[Issue]]:
619
+ """Run the plan's slices, holding back the ones token substitution demoted
620
+ -- and, since tan-cli#483, the ones whose `cores.<id>.app` resolved to a
621
+ directory that does not exist ([`_missing_app_dirs`]) -- both HELD
622
+ per-slice, matching the same per-slice fail/skip shape
623
+ `execute_slices`'s own unknown-backend/missing-tool checks already use,
624
+ rather than refusing the whole plan over one bad core: a two-core project
625
+ with one good `app:` and one bad one still builds the good core.
626
+
627
+ Arms its own [`_Heartbeat`] around the dispatch (tan-cli#287), rather
628
+ than taking one from the caller: `execute_slices` (below) is a single,
629
+ EAGER call that can run for minutes -- the loop after it only walks
630
+ results `execute_slices` already computed -- and this function is the
631
+ one seam every route into the build engine shares. `_build`'s two
632
+ callers, `build_cmd.build` (`tan build`) and `run_cmd._run`
633
+ (`tan run`), both bottleneck through here before any slice runs, so
634
+ arming the heartbeat at THIS level, not one up, is what covers `tan
635
+ run`'s identical silent-CMake window too -- the gap the previous
636
+ revision (a `_Heartbeat` built in `build()` and threaded down as
637
+ `on_output`) left, since `run_cmd.py:267` calls `_build` with no
638
+ `on_output` of its own.
639
+
640
+ `json_mode` is the one thing this function cannot discover for itself
641
+ (`sys.stderr.isatty()` it checks directly, same as `build()` used to).
642
+ `build()` passes its real value straight through. `run_cmd._run` takes
643
+ its own `json_mode` parameter but does not yet thread it into this call
644
+ (out of this file's scope to add -- see `run_cmd.py:267`), so its call
645
+ keeps the default `False`.
646
+
647
+ tan-cli#488 round 5 class sweep: `sys.stderr` can be `None` -- a process
648
+ launched with its standard handles detached (a GUI launcher, a
649
+ `pythonw`-style spawn, or a shell that closed fd 2 before exec) -- the
650
+ same condition `tan.core.consent.can_prompt` guards against. A bare
651
+ `sys.stderr.isatty()` here raised the identical `AttributeError:
652
+ 'NoneType' object has no attribute 'isatty'` for a detached-stdio
653
+ `tan build`/`tan run`, caught only by `build()`'s own outer `except
654
+ Exception` and reported as a fabricated `build.internal-failure` instead
655
+ of simply running with the heartbeat disabled.
656
+
657
+ tan-cli#488 round 6: the round-5 fix only added `sys.stderr is not
658
+ None` in front of the same bare `.isatty()` call -- which stops a
659
+ `None` stderr from crashing this line, but not a stderr that EXISTS and
660
+ simply has no `.isatty()` method at all. `tan.cli.main` installs exactly
661
+ that shape under `--format json`: `sys.stderr` becomes `_TeeStderr`
662
+ (`cli.py`'s tee, `write`/`flush`/`getvalue` only, no `isatty`), which is
663
+ not `None`, so round 5's guard let the call through and
664
+ `run_cmd._run` -> `_build` -> here crashed with `AttributeError:
665
+ '_TeeStderr' object has no attribute 'isatty'` on every `tan run
666
+ --format json` -- exit 5, `run.internal-failure`, before a single slice
667
+ ever dispatched (measured against the real binary). This is the exact
668
+ condition `tan.env.stderr_is_tty` already exists to guard (tan-cli#288)
669
+ and `use_color`/`wrap_width` already share -- a `None` check does not
670
+ cover a stream that exists but lacks the method, which is the whole
671
+ class this line, `tan.core.consent.can_prompt` and `doctor_cmd.
672
+ fix_suppressed_issue` all shared the same wrong shape for. Now routed
673
+ through the one shared probe instead of a fourth hand-rolled copy.
674
+ # ponytail: the one live gap this leaves -- `tan run --format json` with
675
+ # a REAL terminal still attached to stderr would arm the heartbeat where
676
+ # it should stay silent (`run_cmd._run` does not thread its own
677
+ # `json_mode` into `_build`, so `_dispatch` always sees the default
678
+ # `False` on that path -- see `run_cmd.py:267`). stdout (the envelope
679
+ # channel) is untouched either way, and the crash this round fixes is
680
+ # closed regardless of `json_mode`'s value, since `stderr_is_tty()` is
681
+ # false for a `_TeeStderr` on its own merits; threading `json_mode`
682
+ # through `run_cmd._run` is a separate, narrower polish left for its own
683
+ # change, out of this file's scope to add.
684
+
685
+ A demoted slice still names a literal `${TOOLCHAIN_ROOT}` in its own
686
+ fields because this host resolved no toolchain. `execute_slices` does not
687
+ know about demotions, so dispatching one would spawn a command with an
688
+ unsubstituted token in its argv -- a silent wrong-path build, the exact
689
+ failure I-29 says a naive substitution reintroduces. Instead the demotion
690
+ is routed to `executionPolicy.missingTool` (default skip) here, which is
691
+ the seam the Rust executor uses for it too: it is a host-provisioning
692
+ fact, not a plan bug.
693
+
694
+ Precedence is preserved: a demoted slice that ALSO names an unknown
695
+ backend or carries no command is left to `execute_slices`, because both of
696
+ those outrank a provisioning fact and are checked first there.
697
+
698
+ `pristine` (`tan build --pristine`, tan-cli#427) is threaded straight
699
+ through to `execute_slices`'s own `force_pristine` -- see that
700
+ function's docstring, and `_maybe_pristine_stale_sdk_build_dir`'s, for
701
+ the wipe/suppression decision it makes per slice.
702
+
703
+ `sdk_root` is threaded straight through to `execute_slices` -- it is
704
+ THIS run's already-resolved `--sdk-root`/discovered checkout (`_build`'s
705
+ own parameter), the identity `execute_slices`'s sdk-switch-pristine guard
706
+ (issue #52) keys its stamp comparison on. Without it every stamp
707
+ comparison degrades to "unresolved", which never wipes anything -- see
708
+ `tan.core.plan_exec.sdk_stamp_key`. `sdk_root_for_stamp` is the SAME
709
+ checkout, NORMALIZED and anchored on the workspace root (`build()`'s own
710
+ `tan.core.plan_exec.normalize_path(workspace_root / sdk_root)`) -- passed
711
+ separately so a relative `--sdk-root ../alp-sdk` keys the stamp
712
+ IDENTICALLY to the absolute form `tan sdk switch` would pin for the same
713
+ checkout (tan-cli#163), without changing what `sdk_root` itself
714
+ substitutes for `${SDK_ROOT}` or reports as `sdk.root`.
715
+
716
+ Held slices are also threaded into `execute_slices` as `held_outcomes`
717
+ (tan-cli #89 MINOR 4, oracle `execute/mod.rs:379-400`): `execute_slices`
718
+ only ever sees the RUNNABLE subset of the plan (`replace(plan,
719
+ slices=runnable)` below), so its own post-build manifest overlay would
720
+ otherwise never learn a demoted slice existed at all -- leaving that
721
+ core's `system-manifest.yaml` entry at its plan-time (or a previous
722
+ run's) `status`/`output_artefact` forever, which `tan size`/
723
+ `tan debug-config` read with no status check of their own. Each held
724
+ outcome's `output_artefact` is the explicit empty string, never `None`:
725
+ the overlay (`overlay_run_results_raw`) treats `None` as "preserve
726
+ whatever is already there", which is correct for a slice this run never
727
+ touched but wrong for one THIS run knows never dispatched.
728
+ """
729
+ held = {
730
+ d.slice_index: d
731
+ for d in demotions
732
+ if plan.slices[d.slice_index].backend in KNOWN_BACKENDS
733
+ and plan.slices[d.slice_index].command is not None
734
+ }
735
+ action = resolve_action(plan.execution_policy, "missing_tool", PolicyAction.SKIP)
736
+ failed = action is PolicyAction.FAIL
737
+
738
+ # tan-cli#483: a slice already held for `${TOOLCHAIN_ROOT}` keeps that
739
+ # verdict -- a bad `app:` is checked below only for the slices demotion
740
+ # left alone, so the two never disagree about the same index.
741
+ missing_app_dirs = {
742
+ i: msg for i, msg in _missing_app_dirs(plan, build_root).items() if i not in held
743
+ }
744
+ # tan-cli#517, filtered the same way and for the same reason: a slice
745
+ # that will not dispatch has no substitution to announce, and saying so
746
+ # anyway would bury the verdict that actually stopped it.
747
+ substituted_app_dirs = {
748
+ i: msg
749
+ for i, msg in _substituted_app_dirs(plan, build_root).items()
750
+ if i not in held and i not in missing_app_dirs
751
+ }
752
+
753
+ held_outcomes = {
754
+ i: SliceOutcome(
755
+ plan.slices[i].core_id,
756
+ "failed" if failed else "skipped",
757
+ None,
758
+ d.reason,
759
+ output_artefact="",
760
+ )
761
+ for i, d in held.items()
762
+ }
763
+ # A bad app dir is not a host-provisioning gap `executionPolicy` was ever
764
+ # meant to arbitrate (contrast the demotion above) -- it is the plan
765
+ # naming a path that does not exist, so it always FAILS the one slice
766
+ # rather than silently skipping it forward, matching the Expected
767
+ # section of tan-cli#483 ("not a warning that lets the build proceed").
768
+ held_outcomes.update(
769
+ {
770
+ i: SliceOutcome(plan.slices[i].core_id, "failed", None, msg, output_artefact="")
771
+ for i, msg in missing_app_dirs.items()
772
+ }
773
+ )
774
+
775
+ runnable = [
776
+ sl for i, sl in enumerate(plan.slices) if i not in held and i not in missing_app_dirs
777
+ ]
778
+ # tan-cli#287: TTY + text-mode only (never `--format json` -- stdout is
779
+ # untouched either way, this is belt-and-suspenders for a machine caller
780
+ # that also inherited the terminal's stderr). `__enter__`/`__exit__` run
781
+ # regardless of what happens inside, so a dispatch that raises still
782
+ # stops the thread and blanks any line it had printed.
783
+ with _Heartbeat(enabled=not json_mode and stderr_is_tty()) as heartbeat:
784
+ dispatched = iter(
785
+ execute_slices(
786
+ replace(plan, slices=runnable),
787
+ build_root=build_root,
788
+ env_lookup=os.environ.get,
789
+ # tan-cli#308: no build_cmd.py-level gap fillers of our own --
790
+ # `execute_slices` fills ZEPHYR_BASE/EXTRA_ZEPHYR_MODULES
791
+ # itself, PER SLICE, from the west workspace it resolves
792
+ # internally (`tan.core.zephyr_env.zephyr_env_overrides`),
793
+ # exactly where the Rust oracle's own `execute_slices`
794
+ # computes them (`execute/mod.rs`, inside its own per-slice
795
+ # loop) -- not from an outer caller. This parameter stays for
796
+ # a caller-supplied override this port has none of yet.
797
+ gap_fillers=(),
798
+ on_output=heartbeat,
799
+ sdk_root=sdk_root,
800
+ sdk_root_for_stamp=sdk_root_for_stamp,
801
+ held_outcomes=held_outcomes.values(),
802
+ force_pristine=pristine,
803
+ slice_refusals=slice_refusals,
804
+ user_defines=user_defines,
805
+ )
806
+ )
807
+
808
+ outcomes: list[SliceOutcome] = []
809
+ issues: list[Issue] = []
810
+ for i, sl in enumerate(plan.slices):
811
+ demotion = held.get(i)
812
+ if demotion is not None:
813
+ outcomes.append(held_outcomes[i])
814
+ issues.append(
815
+ Issue(
816
+ # Same code as the plan-fatal sibling on purpose: the
817
+ # extension needs no new vocabulary, only a severity
818
+ # that says whether this stopped the build.
819
+ "build.toolchain-root-unresolved",
820
+ "error" if failed else "warning",
821
+ f"slice `{sl.core_id}` {'failed' if failed else 'skipped'}: "
822
+ f"{demotion.reason}",
823
+ )
824
+ )
825
+ continue
826
+ app_dir_message = missing_app_dirs.get(i)
827
+ if app_dir_message is not None:
828
+ outcomes.append(held_outcomes[i])
829
+ issues.append(Issue("build.app-dir-missing", "error", app_dir_message))
830
+ continue
831
+ substituted = substituted_app_dirs.get(i)
832
+ if substituted is not None:
833
+ issues.append(Issue("build.app-dir-substituted", "info", substituted))
834
+ outcome = next(dispatched)
835
+ outcomes.append(outcome)
836
+ if slice_refusals and outcome.message == slice_refusals.get(sl.core_id):
837
+ issues.append(Issue("build.host-python-unsuitable", "error", outcome.message))
838
+ # tan-cli#1351: per-board files naming a different board are silently
839
+ # ignored by Zephyr.
840
+ issues.extend(
841
+ Issue("build.board-file-unmatched", "warning", msg)
842
+ for msg in unmatched_board_file_messages(
843
+ [
844
+ (sl.backend, sl.command.args if sl.command else [], sl.app_dir)
845
+ for sl in plan.slices
846
+ ],
847
+ build_root,
848
+ _known_boards(sdk_root_for_stamp),
849
+ )
850
+ )
851
+ return outcomes, issues
852
+
853
+
854
+ def _known_boards(sdk_root_for_stamp: str | None) -> list[str]:
855
+ """Every board target the SDK's SoM presets declare (tan-cli#1403).
856
+ Takes the workspace-anchored, normalized SDK root (a relative `--sdk-root`
857
+ is anchored on the workspace, not the build root)."""
858
+ if not sdk_root_for_stamp:
859
+ return []
860
+ return all_topology_boards(Path(sdk_root_for_stamp) / "metadata")
861
+
862
+
863
+ #: `os` values the schema requires `cores.<id>.app` to resolve to a real
864
+ #: directory for (`metadata/schemas/board.schema.json`
865
+ #: `$defs/core_entry/properties/app`) -- `os: yocto` dispatches by
866
+ #: `recipe:` (a bitbake recipe name), never by this directory
867
+ #: (`tan/planner/orchestrator.py:338-353`), so checking it there would
868
+ #: refuse a buildable yocto slice over a path the build never reads.
869
+ _APP_DIR_REQUIRED_BACKENDS = frozenset({"zephyr", "baremetal"})
870
+
871
+
872
+ def _missing_app_dirs(plan: BuildPlan, build_root: Path) -> dict[int, str]:
873
+ """Slice indices whose `cores.<id>.app` resolved to a path that is not a
874
+ real, existing directory (tan-cli#483), each mapped to the refusal
875
+ message.
876
+
877
+ Checked HERE, not inside `execute_slices`: by now `apply_plan_token_
878
+ substitution` has resolved `appDir` to a real path, or held the slice
879
+ back as a `${TOOLCHAIN_ROOT}` demotion -- `execute_slices`'s own tests
880
+ feed it synthetic plans with a placeholder `appDir` never meant to
881
+ exist. Neither `orchestrator.py`'s `_zephyr_app_dir` nor
882
+ `_resolve_app_path` checks existence (both FROZEN, `tan/planner/**`);
883
+ unfixed there, a nonexistent `app:` makes `_zephyr_app_dir`'s
884
+ CMakeLists.txt-fallback probe silently find the PROJECT ROOT's own
885
+ CMakeLists.txt instead.
886
+
887
+ Anchored on `build_root`, not the tan process's own CWD: `appDir` is a
888
+ plan path like `buildDir`/`command.cwd`, under the same anchoring
889
+ contract (`build-plan-v1.schema.json`, issue #596) `execute_slices`
890
+ already confines every slice's `cwd` to. The real CWD would refuse a
891
+ valid relative `appDir` from anywhere but the project (e.g.
892
+ `--build-root` from a parent dir), and misname it in the refusal.
893
+
894
+ A slice with no `command` (the planner already refused one --
895
+ `board-tree-missing`, `yocto-recipe-missing`, `no-command`, ...) is
896
+ skipped, mirroring the `${TOOLCHAIN_ROOT}` filter above: nothing was
897
+ going to dispatch for it, so reporting this instead would mask the
898
+ planner's own warning and flip an otherwise-`ok` exit code.
899
+
900
+ EXISTENCE only, not the schema's `prj.conf`/`CMakeLists.txt` contents
901
+ requirement: 96 of 105 enabled zephyr/baremetal examples on alp-sdk
902
+ `dev` are `app: ./src`, sources-only -- the shipped convention
903
+ `_zephyr_app_dir`'s own fallback serves, which a contents check would
904
+ refuse en masse."""
905
+ missing: dict[int, str] = {}
906
+ for i, sl in enumerate(plan.slices):
907
+ if (
908
+ sl.backend not in _APP_DIR_REQUIRED_BACKENDS
909
+ or sl.app_dir is None
910
+ or sl.command is None
911
+ ):
912
+ continue
913
+ app_dir_path = Path(sl.app_dir)
914
+ if not app_dir_path.is_absolute():
915
+ app_dir_path = build_root / app_dir_path
916
+ if not app_dir_path.exists():
917
+ missing[i] = f"core `{sl.core_id}`: app directory does not exist: {app_dir_path}"
918
+ elif not app_dir_path.is_dir():
919
+ missing[i] = f"core `{sl.core_id}`: app path is not a directory: {app_dir_path}"
920
+ return missing
921
+
922
+
923
+ #: The one backend whose app dir the planner may SILENTLY substitute. Only
924
+ #: `_zephyr_app_dir` carries the parent fallback (`tan/planner/
925
+ #: orchestrator.py:397-401`); the `baremetal` arm beside it builds
926
+ #: `cmake -S <_resolve_app_path(app)>` with no `CMakeLists.txt` probe at all,
927
+ #: so nothing is ever substituted there and announcing one would be a lie.
928
+ #: Narrower than `_APP_DIR_REQUIRED_BACKENDS` above for that reason, not by
929
+ #: oversight.
930
+ _APP_DIR_SUBSTITUTING_BACKENDS = frozenset({"zephyr"})
931
+
932
+
933
+ def _substituted_app_dirs(plan: BuildPlan, build_root: Path) -> dict[int, str]:
934
+ """Slice indices whose `cores.<id>.app` resolved to a real directory that
935
+ carries no `CMakeLists.txt`, so `west build` will be pointed at its
936
+ PARENT instead (tan-cli#517), each mapped to the announcement.
937
+
938
+ ANNOUNCED, never refused. The fallback is the shipped convention, not a
939
+ defect: 96 of 105 enabled app-carrying zephyr/baremetal core entries
940
+ across alp-sdk `dev`'s own examples are `app: ./src`, sources-only, with
941
+ the one `CMakeLists.txt` at the example root doing
942
+ `target_sources(app PRIVATE src/main.c)`. Refusing would break 91% of the
943
+ SDK's own examples, which is why tan-cli#517 asks for visibility and
944
+ explicitly not for a refusal. What was wrong was only that it happened
945
+ SILENTLY: on a multi-core project the parent IS the project root, which
946
+ is very often the OTHER core's application, so a typo in one core's
947
+ `app:` that still names an existing directory builds the wrong source
948
+ tree and reports success.
949
+
950
+ `severity: "info"`, not `"warning"`. The measurement above is the
951
+ argument: the substitution fires on the overwhelming majority of real
952
+ builds, so `"warning"` would put a permanent yellow item on nearly every
953
+ successful build in a consumer that surfaces `issues[]` on the ok path
954
+ (alp-sdk-vscode does, since alp-sdk-vscode#485) -- and a warning that is
955
+ always there is one nobody reads on the day it matters. `"info"` is the
956
+ severity this registry already uses for exactly this shape: something
957
+ was substituted/migrated on your behalf and you should be able to see it
958
+ (`debug-config.legacy-entry-migrated`).
959
+
960
+ Named BOTH ways, as the issue asks: the configured path and the
961
+ substituted one. Naming only the substituted one would be indis-
962
+ tinguishable from a correctly-configured self-contained app dir.
963
+
964
+ The probe MIRRORS `_zephyr_app_dir`'s own two-line condition rather than
965
+ reading the substituted path back out of `command.args`. `tan/planner/**`
966
+ is a hash-audited relocation of alp-sdk's `scripts/alp_orchestrate/**`
967
+ (`tests/gates/test_planner_relocation_freshness.py`) and cannot be
968
+ changed here, so the condition is stable; reading `command.args` instead
969
+ would bind this to the ARGV POSITION of `west build`'s app argument,
970
+ which is not a contract anywhere. Note `appDir` is NOT the substituted
971
+ value: `buildplan.py` emits `_resolve_app_path(app)` there, the
972
+ configured path, and only the `west build` positional gets
973
+ `_zephyr_app_dir`'s result -- which is what makes both halves of the
974
+ message recoverable from the plan at all.
975
+
976
+ Guarded exactly like `_missing_app_dirs`: no `command` means nothing was
977
+ going to dispatch, and a nonexistent `app:` is that function's refusal,
978
+ reported there and not softened into an advisory here.
979
+ """
980
+ substituted: dict[int, str] = {}
981
+ for i, sl in enumerate(plan.slices):
982
+ if (
983
+ sl.backend not in _APP_DIR_SUBSTITUTING_BACKENDS
984
+ or sl.app_dir is None
985
+ or sl.command is None
986
+ ):
987
+ continue
988
+ app_dir_path = Path(sl.app_dir)
989
+ if not app_dir_path.is_absolute():
990
+ app_dir_path = build_root / app_dir_path
991
+ if not app_dir_path.is_dir() or (app_dir_path / "CMakeLists.txt").is_file():
992
+ continue
993
+ parent = app_dir_path.parent
994
+ if not (parent / "CMakeLists.txt").is_file():
995
+ # `_zephyr_app_dir`'s third arm: neither has one, so it returns
996
+ # the configured path unchanged. Nothing was substituted, so
997
+ # there is nothing to announce -- the build fails in west/CMake
998
+ # naming the path the project actually configured.
999
+ continue
1000
+ substituted[i] = (
1001
+ f"core `{sl.core_id}`: app directory has no CMakeLists.txt "
1002
+ f"({app_dir_path}) -- building its parent instead ({parent}). This is the "
1003
+ f"`app: ./src` sources-only convention when intended; if it was not, "
1004
+ f"`cores.{sl.core_id}.app` names the wrong directory and this build is "
1005
+ f"against the parent's sources."
1006
+ )
1007
+ return substituted
1008
+
1009
+
1010
+ def _backend_issues(plan: BuildPlan, outcomes: list[SliceOutcome]) -> list[Issue]:
1011
+ """Name the backend string the plan sent that this CLI does not know.
1012
+ Recomputed from the slice rather than sniffed out of the outcome message:
1013
+ `executionPolicy.unknownBackend` already decided skip-vs-fail inside
1014
+ `execute_slices`, and the outcome's status is the honest read of it."""
1015
+ issues = []
1016
+ for sl, outcome in zip(plan.slices, outcomes, strict=True):
1017
+ if sl.backend in KNOWN_BACKENDS:
1018
+ continue
1019
+ failed = outcome.status == "failed"
1020
+ issues.append(
1021
+ Issue(
1022
+ "build.unknown-backend",
1023
+ "error" if failed else "warning",
1024
+ f"slice `{sl.core_id}` names unrecognised backend `{sl.backend}`"
1025
+ + ("" if failed else " -- skipped"),
1026
+ )
1027
+ )
1028
+ return issues
1029
+
1030
+
1031
+ def _missing_tool_issues(plan: BuildPlan, outcomes: list[SliceOutcome]) -> list[Issue]:
1032
+ """Name the tool a skipped (or, under `executionPolicy.missingTool: fail`,
1033
+ failed) slice could not find on this host.
1034
+
1035
+ Matched on `outcome.message` rather than re-probing PATH a second time:
1036
+ every missing-tool refusal `execute_slices` (`build/execute.py`) can
1037
+ produce carries [`MISSING_TOOL_RE`]'s shape somewhere in the message --
1038
+ a null-command skip reads `` has no command `` (and its reason already
1039
+ lives in `plan.warnings`, I-11), and an unknown-backend one is caught
1040
+ structurally by `_backend_issues` above -- so this recovers exactly the
1041
+ missing-tool cases and nothing else.
1042
+
1043
+ tan-cli#801: this used to require the marker at the message's START
1044
+ (`message.startswith("tool `")`), which matched only the slice's own
1045
+ `command` precheck. The #550 post-build-step refusal
1046
+ (`_missing_post_tool`) puts the SAME phrase after a `` slice `{core_id}`
1047
+ post-build step N of M (`{label}`) cannot run: `` lead-in, so it never
1048
+ started with the marker and this promoter silently dropped it -- the
1049
+ exact silence tan-cli#283 was filed to close, just for a different
1050
+ refusal site. `.search`, not `startswith`: the marker's position is not
1051
+ part of the contract, only whether the message carries it at all.
1052
+
1053
+ tan-cli#283: without this, `tan build` on a host missing `west`/`bitbake`
1054
+ reported each slice's specific reason only in `data.slices[].reason` --
1055
+ never in `issues[]`, the field a JSON consumer (the VS Code extension,
1056
+ CI) actually reads to decide whether anything needs flagging."""
1057
+ issues = []
1058
+ for sl, outcome in zip(plan.slices, outcomes, strict=True):
1059
+ message = outcome.message
1060
+ if message is None or not MISSING_TOOL_RE.search(message):
1061
+ continue
1062
+ failed = outcome.status == "failed"
1063
+ issues.append(
1064
+ Issue(
1065
+ "build.missing-tool",
1066
+ "error" if failed else "warning",
1067
+ f"slice `{sl.core_id}` {'failed' if failed else 'skipped'}: {message}",
1068
+ )
1069
+ )
1070
+ return issues
1071
+
1072
+
1073
+ def _host_python_issues(build_python, mode) -> list[Issue]:
1074
+ """tan-cli#1317. Where CMake will NOT run (`--materialise`) the refusal is a
1075
+ plain warning and the bare fallback stays. On the native dispatch it is a
1076
+ PER-SLICE outcome instead (`_python_refusals` -> `execute_slices`), applied
1077
+ after `executionPolicy`'s null-command / missing-tool skips."""
1078
+ if build_python.refusal is None or mode == _MODE_NATIVE:
1079
+ return []
1080
+ return [Issue("build.host-python-unsuitable", "warning", build_python.refusal)]
1081
+
1082
+
1083
+ def _python_refusals(build_python, demotions, mode) -> dict[str, str]:
1084
+ if build_python.refusal is None or mode != _MODE_NATIVE:
1085
+ return {}
1086
+ message = refusal_message(build_python.refusal)
1087
+ return {
1088
+ c: f"core `{c}`: {message}"
1089
+ for c in build_python.users - {d.core_id for d in demotions}
1090
+ }
1091
+
1092
+
1093
+ def _cross_drive_issues(outcomes: list[SliceOutcome]) -> list[Issue]:
1094
+ """tan-cli#697: promote `tan.core.plan_exec.cross_drive_source_refusal`'s
1095
+ refusal -- matched on its own `CROSS_DRIVE_MSG` marker, same idiom as
1096
+ `_missing_tool_issues` above -- from a per-slice `reason` into a coded
1097
+ top-level `issues[]` entry. Without this the envelope carried only the
1098
+ generic `build.slice-failed` header (`_build`, below) for a cross-drive
1099
+ project/workspace layout, indistinguishable from any other build
1100
+ failure to a JSON consumer that reads `issues[]` and never `data.
1101
+ slices[].reason`. Always `failed`, never `skipped`: this refusal is a
1102
+ hardcoded plan/host-layout defect (mirroring the unsafe-cwd escape
1103
+ above it in `execute.py`), not something `executionPolicy` governs."""
1104
+ issues = []
1105
+ for outcome in outcomes:
1106
+ if outcome.message is None or CROSS_DRIVE_MSG not in outcome.message:
1107
+ continue
1108
+ issues.append(Issue("build.cross-drive-workspace", "error", outcome.message))
1109
+ return issues
1110
+
1111
+
1112
+ def _workspace_unresolved_issues(outcomes: list[SliceOutcome]) -> list[Issue]:
1113
+ """tan-cli#1429: `_cross_drive_issues`' promotion for the refusal in
1114
+ `tan.core.west_workspace_refusal`, matched on its own marker."""
1115
+ return [
1116
+ Issue("build.workspace-unresolved", "error", o.message)
1117
+ for o in outcomes
1118
+ if o.message is not None and WORKSPACE_UNRESOLVED_MSG in o.message
1119
+ ]
1120
+
1121
+
1122
+ def _plan_warning_issues(warnings: list[dict]) -> list[Issue]:
1123
+ """tan-cli#1000: promote the PLAN's own warnings into `issues[]`.
1124
+
1125
+ Third sibling of `_missing_tool_issues` (tan-cli#283/#801) and
1126
+ `_cross_drive_issues` (tan-cli#697), and filed because it was the door
1127
+ those two left open. `plan.warnings` reached exactly one place -- the
1128
+ envelope's `data.warnings` -- so the message that says WHY a build
1129
+ produced nothing was invisible to text mode and to any consumer reading
1130
+ `issues[]`, which is the field alp-sdk-vscode and CI actually branch on.
1131
+
1132
+ Measured on `E1M-AEN301`, a SKU `tan presets` lists: `tan init` rc=0,
1133
+ `tan validate` rc=0 "clean", `tan build` rc=1 with a four-line text
1134
+ output whose only cause-bearing words were ``slice `m55_hp` has no
1135
+ command``, while `data.warnings` held "SoM 'E1M-AEN301' core 'm55_hp'
1136
+ wants Zephyr board 'alp_e1m_aen301_m55_hp', which has no tree under
1137
+ zephyr/boards/alp/ -- board bring-up for this target has not happened
1138
+ yet."
1139
+
1140
+ ONE registered code for every warning, not `build.<the warning's own
1141
+ code>`: `build_plan.py`'s `_warnings` documents the warning codes as an
1142
+ OPEN set ("the forward compatibility is in the CODES being open"), so
1143
+ minting an issue code per warning code would emit unregistered codes the
1144
+ moment the planner adds one -- and `contract/issue-codes.json` is a
1145
+ closed registry whose consumer matches with `===`. The warning's own code
1146
+ travels inside the message instead, where an open set costs nothing.
1147
+
1148
+ Severity is always `warning`, never promoted to `error` on the count: a
1149
+ build that fails BECAUSE of these already carries its own
1150
+ `build.nothing-built`/`build.slice-failed` error beside them, and a
1151
+ partial build where one core lacks a board tree is genuinely not an
1152
+ error for the cores that did build.
1153
+
1154
+ Text mode needs no separate change -- `_print_text_issues` prints every
1155
+ surviving issue as ``{severity}: {message}``, and `_text_issues`'
1156
+ dedup (tan-cli#746) only drops a message that ENDS WITH a slice's own
1157
+ `reason`, which a warning's text does not.
1158
+ """
1159
+ issues = []
1160
+ for warning in warnings:
1161
+ code = warning.get("code")
1162
+ core_id = warning.get("coreId")
1163
+ message = warning.get("message")
1164
+ where = f" {core_id}:" if core_id else ""
1165
+ issues.append(
1166
+ Issue("build.plan-warning", "warning", f"[{code}]{where} {message}")
1167
+ )
1168
+ return issues
1169
+
1170
+
1171
+ def _toolchain_for_plan(plan_text: str) -> ToolchainResolution:
1172
+ """This host's `${TOOLCHAIN_ROOT}` -- resolved ONLY when `plan_text`
1173
+ actually names the token (tan-cli#547).
1174
+
1175
+ The laziness is the point, and it is a property of THIS call site rather
1176
+ than of `resolve_toolchain_root` itself: no SDK plan names
1177
+ `${TOOLCHAIN_ROOT}` today, and every one of those builds must keep
1178
+ costing zero filesystem scans of `/opt` and `$HOME`. It also keeps the
1179
+ resolver's own failure modes (an unreadable scan root, an ambiguous
1180
+ host) entirely out of the way of a plan that would never have consumed
1181
+ the value.
1182
+
1183
+ Asked of the plan's re-serialised JSON, not of `plan_text` directly: a
1184
+ plan is free to spell the token's `$` as `\\u0024`, which a raw substring
1185
+ search would miss while `substitute_plan_tokens` -- which sees the
1186
+ DECODED string -- would not, leaving the slice demoted on a host that
1187
+ has a toolchain. `plan_text` is already known to parse (`_acquire_plan`
1188
+ returned a `BuildPlan` from it), so the round trip cannot fail here.
1189
+
1190
+ An untokened plan (`planPathMode` absent) never reaches substitution at
1191
+ all, but is not special-cased: it also never contains the token, so the
1192
+ same one check covers both.
1193
+ """
1194
+ if TOKEN_TOOLCHAIN_ROOT not in json.dumps(json.loads(plan_text)):
1195
+ return ToolchainResolution(None)
1196
+ return resolve_toolchain_root()
1197
+
1198
+
1199
+ def _build_root_is_consumer_default(build_root: str) -> bool:
1200
+ """Whether a plan's `buildRoot` names the ONE tree this consumer reads
1201
+ back (tan-cli#566) -- `BuildPlan::build_root_is_consumer_default` in the
1202
+ oracle.
1203
+
1204
+ A PATH-COMPONENT comparison, not a string equality, and that distinction
1205
+ is load-bearing in both directions. MEASURED against `target/debug/tan`
1206
+ (`tan 0.4.1`), driving its live-emit native path through a stub planner
1207
+ so an arbitrary `buildRoot` could be handed to it, ten spellings, only
1208
+ that field varying:
1209
+
1210
+ build rc 0 accepted
1211
+ build/ rc 0 accepted <- a naive `!= "build"` refuses this
1212
+ ./build rc 0 accepted <- and this
1213
+ out rc 1 build.unsupported-build-root
1214
+ build/sub rc 1 build.unsupported-build-root
1215
+ BUILD rc 1 build.unsupported-build-root (case SENSITIVE)
1216
+ (empty) rc 1 build.unsupported-build-root
1217
+ .. rc 1 build.unsupported-build-root
1218
+ ../build rc 1 build.unsupported-build-root
1219
+ /tmp/abs/build rc 1 build.unsupported-build-root
1220
+
1221
+ `Path.parts` reproduces all ten: it drops a trailing separator and a
1222
+ leading `./` (so the two accepted aliases stay accepted), keeps `..` and
1223
+ a leading root as components of their own, and is empty for `""`.
1224
+ """
1225
+ return Path(build_root).parts == (CONSUMER_BUILD_ROOT,)
1226
+
1227
+
1228
+ def _demoted_artefact_issues(plan: BuildPlan, demotions) -> list[Issue]:
1229
+ """`executionPolicy.missingTool`, applied to a MATERIALISE run's token
1230
+ demotions (tan-cli#565) -- RAISES on `fail`, returns one warning per
1231
+ demoted slice on `skip`.
1232
+
1233
+ `substitute_plan_tokens` STRIPS a demoted slice's `configArtefacts`, so
1234
+ without this the files that slice's core needs are simply absent from
1235
+ both `written` and the disk, at exit 0 with `issues: []` -- a CI step
1236
+ that materialises and then runs `west build` for that core gets DEFAULT
1237
+ Kconfig instead of the project's, with no signal on either side. Routed
1238
+ to `missingTool` because a demotion is a host-provisioning fact, the same
1239
+ seam `_dispatch` routes it to for a run that goes on to dispatch.
1240
+
1241
+ NOT filtered the way `_dispatch`'s `held` is. There, a demoted slice
1242
+ whose backend is unknown or whose `command` is null is left alone because
1243
+ something else already outranks a provisioning fact AT DISPATCH. Nothing
1244
+ is being dispatched here -- the question is only whether a file was
1245
+ written -- so every demotion counts. MEASURED against the oracle with a
1246
+ four-slice plan (demoted + `command: null`; demoted + unknown backend;
1247
+ demoted + ordinary; undemoted): all three demoted slices were reported,
1248
+ under both actions, and only the undemoted slice's artefact was written.
1249
+
1250
+ The two shapes are the oracle's own, verbatim:
1251
+
1252
+ * `skip` -> exit 0, one `warning` PER slice, "slice `x`:
1253
+ configArtefacts not materialised — <reason>".
1254
+ * `fail` -> exit 1, `data: null`, ONE `error` whose message is every
1255
+ slice's "slice `x`: <reason>" sentence joined by newlines.
1256
+
1257
+ `reason` is threaded through verbatim from the demotion rather than
1258
+ re-derived, exactly as `_dispatch` does -- it is the second half of one
1259
+ toolchain resolution (`ToolchainResolution.advice`), and re-deriving it
1260
+ here would let the sentence drift from the value it explains.
1261
+ """
1262
+ action = resolve_action(plan.execution_policy, "missing_tool", PolicyAction.SKIP)
1263
+ if action is PolicyAction.FAIL:
1264
+ raise BuildError(
1265
+ "build.toolchain-root-unresolved",
1266
+ "\n".join(f"slice `{d.core_id}`: {d.reason}" for d in demotions),
1267
+ ExitCode.RUNTIME_FAILURE,
1268
+ )
1269
+ return [
1270
+ Issue(
1271
+ "build.toolchain-root-unresolved",
1272
+ "warning",
1273
+ f"slice `{d.core_id}`: configArtefacts not materialised — {d.reason}",
1274
+ )
1275
+ for d in demotions
1276
+ ]
1277
+
1278
+
1279
+ def _build(
1280
+ *,
1281
+ # Defaulted so `mode` stays optional for the OTHER caller of this engine:
1282
+ # `run_cmd._run` builds unconditionally (there is no `tan run
1283
+ # --plan`/`--materialise`) and has no mode to pass.
1284
+ mode: str = _MODE_NATIVE,
1285
+ plan_from: str | None,
1286
+ build_root: str,
1287
+ sdk_root: str | None,
1288
+ sdk_root_for_stamp: str | None,
1289
+ board_yaml: str | None,
1290
+ # Defaulted for the same reason as `mode`: `run_cmd._run`
1291
+ # calls this with no format preference of its own today. Threaded
1292
+ # straight through to `_dispatch`, which is where the heartbeat this
1293
+ # gates is actually armed -- see that function's own docstring.
1294
+ json_mode: bool = False,
1295
+ # Defaulted for the same reason as `mode`/`json_mode`: `run_cmd._run` has
1296
+ # no `tan build --pristine` equivalent of its own yet (`tan run` has no
1297
+ # `--pristine` flag). Threaded straight through to `_dispatch`.
1298
+ pristine: bool = False,
1299
+ # tan-cli#1359: the synthesised single-Zephyr-slice plan text for a
1300
+ # `board.yaml`-less example (`tan build --board`). Replaces the planner.
1301
+ plain_plan_text: str | None = None,
1302
+ # tan-cli#1382: normalised `-D` args (and optional `--core` scope) spliced
1303
+ # into a PLANNED build's Zephyr slices. Unused on the plain route, which
1304
+ # already carries its defines in `plain_plan_text`.
1305
+ defines: list[str] | None = None,
1306
+ define_cores: list[str] | None = None,
1307
+ ) -> tuple[ExitCode, dict, list[Issue]]:
1308
+ define_slices: list[str] = []
1309
+ if plain_plan_text is not None:
1310
+ text = plain_plan_text
1311
+ try:
1312
+ plan = parse_build_plan(text)
1313
+ except PlanParseError as err: # pragma: no cover -- tan's own plan
1314
+ raise BuildError(err.code, err.message, ExitCode.RUNTIME_FAILURE) from err
1315
+ if defines:
1316
+ define_slices = [sl.core_id for sl in plan.slices]
1317
+ else:
1318
+ text, plan = _acquire_plan(plan_from, sdk_root, board_yaml)
1319
+ if defines:
1320
+ try:
1321
+ text, define_slices = apply_user_defines(text, defines, define_cores)
1322
+ except UserDefineError as err:
1323
+ raise BuildError(err.code, err.message, ExitCode.VALIDATION_FAILURE) from err
1324
+ plan = parse_build_plan(text)
1325
+
1326
+ if mode == _MODE_PLAN:
1327
+ # Parsed (so a plan that will not load is still refused with its own
1328
+ # code -- measured: the oracle rejects `schemaVersion: 2` and an
1329
+ # unknown `executionPolicy` action in THIS mode too), then echoed
1330
+ # verbatim. No token substitution: the oracle shows a `planPathMode:
1331
+ # tokened` plan with no SDK resolvable at exit 0, unsubstituted, and
1332
+ # only refuses it once `--materialise` asks for it on disk.
1333
+ return ExitCode.SUCCESS, json.loads(text), []
1334
+
1335
+ # Substitution runs on the in-memory plan BEFORE materialise writes
1336
+ # anything and before any command is assembled, so an unresolvable token
1337
+ # can never reach disk or an argv. A no-op on an untokened plan -- e.g. an
1338
+ # old `--plan-from` file (the planner itself now tags every plan tokened).
1339
+ toolchain = _toolchain_for_plan(text)
1340
+ deferred: list[DeferredPlaceholder] = []
1341
+ build_python = resolve_build_python(plan, text, build_root, sdk_root)
1342
+ try:
1343
+ plan, demotions = apply_plan_token_substitution(
1344
+ plan,
1345
+ board_yaml_path=board_yaml,
1346
+ exec_base=build_root,
1347
+ sdk_root=sdk_root,
1348
+ python=build_python.python,
1349
+ toolchain_root=toolchain.root,
1350
+ toolchain_advice=toolchain.advice,
1351
+ deferred_out=deferred,
1352
+ project_root=build_root if plain_plan_text is not None else None,
1353
+ )
1354
+ except TokenSubstitutionError as err:
1355
+ # RuntimeFailure for every code this pass raises, `build.plan-invalid`
1356
+ # (an unknown `planPathMode`) included -- same ladder position the Rust
1357
+ # oracle gives them (`native.rs:132-142`), and the same code must not
1358
+ # mean two different exits depending on which module raised it.
1359
+ raise BuildError(err.code, err.message, ExitCode.RUNTIME_FAILURE) from err
1360
+
1361
+ python_issues = _host_python_issues(build_python, mode)
1362
+
1363
+ # tan-cli#566, and tan-cli#565's `fail` arm: BOTH sit here, between
1364
+ # substitution and `materialise_plan` below, because both must leave a
1365
+ # refused run with NOTHING on disk. `materialise_plan` runs unconditionally
1366
+ # and above the mode check, so a guard placed inside the `_MODE_MATERIALISE`
1367
+ # block below would refuse only AFTER the other slices' files had landed --
1368
+ # measured, the oracle's own refusals leave the tree empty in both cases.
1369
+ #
1370
+ # After substitution, not before, also measured: a plan that is BOTH
1371
+ # `planPathMode: legacy` and `buildRoot: out` is refused by the oracle with
1372
+ # `build.plan-invalid`, not `build.unsupported-build-root`, so the
1373
+ # substitution pass keeps its precedence.
1374
+ #
1375
+ # NATIVE only for the build root (tan-cli#566). The oracle accepts
1376
+ # `buildRoot: out` under `--materialise` AND `--plan` and refuses it only on
1377
+ # the path that goes on to DISPATCH -- measured, all three modes -- and that
1378
+ # split is the right one rather than an inconsistency to iron out: the
1379
+ # hazard is `system-manifest.yaml`, which only a dispatching run writes.
1380
+ # `_MODE_NATIVE` is also what `--execute` and `run_cmd._run` select, so both
1381
+ # of those are covered by the same one check. Nothing narrows to
1382
+ # `--plan-from`: every live plan the in-process planner emits has
1383
+ # `buildRoot: build` (`tan.planner.cli`/`planner_root.emit` default it to
1384
+ # `Path("build")` and `_emit_plan` never overrides), so this can only ever
1385
+ # fire on a plan file, which is exactly the shape the issue reports.
1386
+ if mode == _MODE_NATIVE and not _build_root_is_consumer_default(plan.build_root):
1387
+ raise BuildError(
1388
+ "build.unsupported-build-root",
1389
+ f"plan buildRoot `{plan.build_root}` is not `{CONSUMER_BUILD_ROOT}`; "
1390
+ "tan's flash/size/image read "
1391
+ "`<project>/build/system-manifest.yaml`, so building elsewhere "
1392
+ "would leave them reading a stale or missing manifest",
1393
+ ExitCode.RUNTIME_FAILURE,
1394
+ )
1395
+
1396
+ # MATERIALISE only for the demotions (tan-cli#565): `_MODE_NATIVE` already
1397
+ # routes them, per slice, through `_dispatch`'s own `missing_tool` seam, and
1398
+ # `_MODE_PLAN` returned above without substituting at all. Raises on `fail`,
1399
+ # so nothing below runs; returns the per-slice warnings on `skip`, carried
1400
+ # to the return statement rather than emitted here.
1401
+ demotion_issues = (
1402
+ _demoted_artefact_issues(plan, demotions)
1403
+ if mode == _MODE_MATERIALISE and demotions
1404
+ else []
1405
+ )
1406
+ # tan-cli#1302: placeholders deliberately left in a config artefact, and a
1407
+ # malformed `deferredPlaceholders`. Reported on EVERY path that
1408
+ # materialises, so the native build says it too, not just `--materialise`.
1409
+ deferred_issues = deferred_placeholder_issues(plan, deferred)
1410
+
1411
+ # I-20. ALL of them, then dispatch -- never interleaved.
1412
+ try:
1413
+ materialise_plan(plan, Path(build_root))
1414
+ except MaterialiseError as err:
1415
+ raise BuildError(
1416
+ "build.materialise-failed", err.message, ExitCode.WRITE_FAILURE
1417
+ ) from err
1418
+
1419
+ if mode == _MODE_MATERIALISE:
1420
+ # Write and stop -- the oracle dispatches nothing here, and its `data`
1421
+ # carries no `slices` at all. `written` is rebuilt from the plan's own
1422
+ # (post-substitution) artefact paths in `materialise_plan`'s documented
1423
+ # order, shared first then per slice, because those are the RELATIVE
1424
+ # strings the oracle reports; `materialise_plan` itself hands back
1425
+ # resolved absolute paths, and relativising those back re-introduces
1426
+ # every symlink/case difference `_abs_posix` exists to avoid.
1427
+ written = [a["path"] for a in plan.shared_artefacts]
1428
+ for sl in plan.slices:
1429
+ written.extend(a["path"] for a in sl.config_artefacts)
1430
+ return (
1431
+ ExitCode.SUCCESS,
1432
+ {"schemaVersion": "1", "baseDir": build_root, "written": written},
1433
+ demotion_issues + deferred_issues + python_issues,
1434
+ )
1435
+
1436
+ # Cleared before dispatch, mirroring `run_cmd.py`'s own pattern, so a
1437
+ # leftover signal from an earlier in-process `_build` (e.g. a test
1438
+ # harness calling `build()` more than once) never gets misread as THIS
1439
+ # run's own write outcome below.
1440
+ reset_last_manifest_write()
1441
+ # tan-cli#1376: advisory only -- never changes `ok`/the exit code.
1442
+ patch_issues = workspace_patch_issues(
1443
+ Path(build_root),
1444
+ sdk_root,
1445
+ has_zephyr_slice=any(sl.backend == "zephyr" for sl in plan.slices),
1446
+ )
1447
+ outcomes, issues = _dispatch(
1448
+ plan,
1449
+ demotions,
1450
+ Path(build_root),
1451
+ sdk_root,
1452
+ sdk_root_for_stamp,
1453
+ json_mode=json_mode,
1454
+ pristine=pristine,
1455
+ slice_refusals=_python_refusals(build_python, demotions, mode),
1456
+ user_defines={c: define_pairs(defines) for c in define_slices} if defines else None,
1457
+ )
1458
+
1459
+ any_failed = any(o.status not in ("succeeded", "skipped") for o in outcomes)
1460
+ # tan-cli#283: a plan whose EVERY slice was skipped (e.g. `west`/
1461
+ # `bitbake` absent from PATH) used to fall straight through this `if
1462
+ # any_failed` gate -- `skipped` is deliberately not a failure status on
1463
+ # its own line above -- and report `ok: true, exitCode: 0, issues: []`.
1464
+ # A JSON consumer cannot tell that from a real build. A PARTIAL build
1465
+ # (some slice built -- e.g. only the Zephyr cores, on a host with no
1466
+ # Yocto toolchain) is a legitimate, deliberate outcome and must stay
1467
+ # `ok: true`; refusing outright only when NOTHING built at all is the
1468
+ # narrower fix, not "any skip is now a failure".
1469
+ nothing_built = (
1470
+ bool(outcomes)
1471
+ and not any_failed
1472
+ and not any(o.status == "succeeded" for o in outcomes)
1473
+ )
1474
+ exit_code = ExitCode.RUNTIME_FAILURE if (any_failed or nothing_built) else ExitCode.SUCCESS
1475
+ if any_failed:
1476
+ issues.insert(
1477
+ 0, Issue("build.slice-failed", "error", "one or more slices failed to build")
1478
+ )
1479
+ elif nothing_built:
1480
+ issues.insert(
1481
+ 0,
1482
+ Issue(
1483
+ "build.nothing-built",
1484
+ "error",
1485
+ "no slice was built -- every slice was skipped",
1486
+ ),
1487
+ )
1488
+ issues.extend(_backend_issues(plan, outcomes))
1489
+ # tan-cli#283: named per skipped/failed slice, even in the PARTIAL case
1490
+ # `nothing_built` does not cover -- a consumer reading only `issues[]`
1491
+ # (not `data.slices[]`) must still be able to tell "2 of 3 slices built"
1492
+ # from "3 of 3".
1493
+ issues.extend(_missing_tool_issues(plan, outcomes))
1494
+ # tan-cli#697: named per cross-drive-refused slice, same reasoning as
1495
+ # `_missing_tool_issues` immediately above -- a JSON consumer reading
1496
+ # only `issues[]` must see the specific cause, not just the generic
1497
+ # `build.slice-failed` header this promotion sits alongside.
1498
+ issues.extend(_cross_drive_issues(outcomes))
1499
+ issues.extend(_workspace_unresolved_issues(outcomes))
1500
+ # tan-cli#1000: the PLAN's own warnings, same reasoning again -- these
1501
+ # are the only place the planner explains a `command: null` slice, and
1502
+ # they reached `data.warnings` alone until now.
1503
+ issues.extend(_plan_warning_issues(plan.warnings))
1504
+ issues.extend(deferred_issues)
1505
+ issues.extend(python_issues)
1506
+ issues.extend(patch_issues)
1507
+ # The sdk-switch-pristine wipe (issue #52) must not be stderr-only in
1508
+ # JSON mode -- the VS Code extension only ever sees the envelope, not
1509
+ # `_stream`'s output. Verbatim oracle codes/severity
1510
+ # (`crates/tan-cli/src/commands/build/execute/mod.rs:591,617`):
1511
+ # `build.sdk-switch-pristine` for the wipe itself, `-failed` when the
1512
+ # wipe could not fully land.
1513
+ issues.extend(last_sdk_switch_issues())
1514
+ # tan-cli#655: the stale-configure-cache reset must not be stderr-only in
1515
+ # JSON mode either -- same reasoning as the sdk-switch-pristine wipe
1516
+ # immediately above.
1517
+ issues.extend(last_configure_cache_issues())
1518
+
1519
+ manifest_reason = last_manifest_write_failure()
1520
+ # The plain route (`--board`) has its own writer (tan-cli#1381), so a failed
1521
+ # write there is as real as on the planned route (tan-cli#1517).
1522
+ if manifest_reason is not None:
1523
+ # A failed manifest write used to report only through `on_output`
1524
+ # (stderr) -- so the envelope said `ok: true, issues: []` while
1525
+ # `system-manifest.yaml` still named the PREVIOUS run's status and
1526
+ # artefact, which `tan size` re-derives and `tan flash` would go on
1527
+ # to program. Verbatim oracle code/message
1528
+ # (`crates/tan-cli/src/commands/build/execute/mod.rs:809-814`):
1529
+ # dropping this half is exactly how a stale manifest used to look
1530
+ # identical to `ok: true`.
1531
+ issues.append(
1532
+ Issue(
1533
+ "build.manifest-write-failed",
1534
+ "warning",
1535
+ f"system-manifest.yaml was not updated — {manifest_reason}",
1536
+ )
1537
+ )
1538
+
1539
+ data = {
1540
+ "schemaVersion": "1",
1541
+ "baseDir": build_root,
1542
+ "slices": [
1543
+ _slice_result(sl.core_id, sl.backend, outcome)
1544
+ for sl, outcome in zip(plan.slices, outcomes, strict=True)
1545
+ ],
1546
+ # The plan's own warnings, verbatim. A `command: null` slice is a
1547
+ # legitimate skip whose REASON lives only here (I-11) -- dropping the
1548
+ # list leaves the user a slice that silently did not build. Passed
1549
+ # through untyped on purpose: the schema says new warning codes may
1550
+ # appear without a schemaVersion bump, so a consumer must not treat
1551
+ # them as a closed set, and neither may this.
1552
+ "warnings": plan.warnings,
1553
+ }
1554
+ if defines and define_slices:
1555
+ data["defines"] = {"args": list(defines), "slices": define_slices}
1556
+ return exit_code, data, issues
1557
+
1558
+
1559
+ def _plain_core_id(board: str, sdk_root: str | None) -> str | None:
1560
+ """The planner core id (`m55_he`) the plain route's board target maps to,
1561
+ from the bound SDK's SoM presets; `None` (the slice keeps the board
1562
+ qualifier's name) when the SDK or the target is unknown (tan-cli#1370)."""
1563
+ if sdk_root is None or not is_sdk_root(sdk_root):
1564
+ return None
1565
+ try:
1566
+ from tan.planner_root import bind_sdk_root
1567
+
1568
+ bind_sdk_root(sdk_root)
1569
+ from tan.planner.plain_slice import plain_core_id
1570
+
1571
+ return plain_core_id(board, Path(sdk_root) / "metadata")
1572
+ except Exception: # noqa: BLE001 -- best effort; the fallback name still builds
1573
+ return None
1574
+
1575
+
1576
+ def _refuse_plain_route(
1577
+ board: str | None,
1578
+ define: list[str] | None,
1579
+ board_yaml: str | None,
1580
+ plan_from: str | None,
1581
+ mode: str,
1582
+ json_mode: bool,
1583
+ ) -> None:
1584
+ """Refuse a bad `--board` / `-D` combination up front (exit 2), else return."""
1585
+ conflict = None
1586
+ if board is not None and (board_yaml is not None or plan_from is not None):
1587
+ flag = "--board-yaml" if board_yaml is not None else "--plan-from"
1588
+ conflict = (
1589
+ "`--board` (a plain Zephyr example, no board.yaml) cannot be combined with "
1590
+ f"`{flag}` (a planned build). Pick one route."
1591
+ )
1592
+ elif board is not None and mode != _MODE_NATIVE:
1593
+ conflict = "`--board` only builds; drop `--materialise`."
1594
+ if conflict is not None:
1595
+ _refuse("build.conflicting-flags", conflict, ExitCode.VALIDATION_FAILURE, json_mode)
1596
+ problem = (board_target_problem(board) if board is not None else None) or normalise_defines(
1597
+ define or []
1598
+ )[1]
1599
+ if problem is not None:
1600
+ _refuse("build.invalid-argument", problem, ExitCode.VALIDATION_FAILURE, json_mode)
1601
+ reserved = user_defines_problem(normalise_defines(define or [])[0])
1602
+ if reserved is not None and reserved[0] == "build.define-reserved":
1603
+ _refuse("build.define-reserved", reserved[1], ExitCode.VALIDATION_FAILURE, json_mode)
1604
+ if reserved is not None:
1605
+ _refuse("build.invalid-argument", reserved[1], ExitCode.VALIDATION_FAILURE, json_mode)
1606
+
1607
+
1608
+ def _refuse(code: str, message: str, exit_code: ExitCode, json_mode: bool) -> None:
1609
+ """Report a refusal this command makes UP FRONT -- before any project is
1610
+ resolved, any plan read, and any byte written -- and exit.
1611
+
1612
+ One helper for both of them (a retired flag, and a conflicting flag pair)
1613
+ because they differ only in code, message and exit code. `project` is left
1614
+ unresolved: nothing was read, so reporting a root would be reporting work
1615
+ this run did not do."""
1616
+ issue = Issue(code, "error", message)
1617
+ if json_mode:
1618
+ emit(
1619
+ Envelope(
1620
+ "build",
1621
+ Project(root=None, board_yaml=None),
1622
+ {"message": message},
1623
+ [issue],
1624
+ exit_code,
1625
+ )
1626
+ )
1627
+ else:
1628
+ print(f"error: {message}", file=sys.stderr)
1629
+ raise typer.Exit(int(exit_code))
1630
+
1631
+
1632
+ def build(
1633
+ plan_from: str = typer.Option(
1634
+ None,
1635
+ "--plan-from",
1636
+ metavar="FILE",
1637
+ help="Read the build plan from a JSON file instead of invoking the SDK planner. "
1638
+ "Implies --plan: shows the plan and exits unless --materialise or --execute "
1639
+ "is given.",
1640
+ ),
1641
+ materialise: bool = typer.Option(
1642
+ False,
1643
+ "--materialise",
1644
+ help="Write the plan's generated files (shared artefacts + per-slice config) "
1645
+ "under the build root and stop, instead of just showing the plan.",
1646
+ ),
1647
+ # Accepted and INERT, exactly as in the oracle ("This is the default; the
1648
+ # flag is kept as an explicit opt-in" -- its own `--native` help). Never
1649
+ # read below on purpose: a bare build is already native, and letting it
1650
+ # override the `--plan` implied by `--plan-from` is the divergence
1651
+ # `test_plan_from_shows_the_plan_and_writes_nothing_even_with_native`
1652
+ # exists to keep out. `--execute` is the flag that dispatches a
1653
+ # file-supplied plan.
1654
+ native: bool = typer.Option(
1655
+ False,
1656
+ "--native",
1657
+ help="Build natively: materialise the plan, then run each slice's command. "
1658
+ "The default when no plan-mode flag is given. Does NOT override the --plan "
1659
+ "implied by --plan-from -- use --execute for that.",
1660
+ ),
1661
+ execute: bool = typer.Option(
1662
+ False,
1663
+ "--execute",
1664
+ help="Materialise the plan AND run each slice's command, even when the plan "
1665
+ "came from --plan-from -- run a pinned, reviewed plan file reproducibly. "
1666
+ "Implies --materialise (nothing can run that was never written); reports the "
1667
+ "ordinary build result.",
1668
+ ),
1669
+ build_root: str = typer.Option(
1670
+ None,
1671
+ "--build-root",
1672
+ metavar="DIR",
1673
+ help="Project tree the slices run under and artefacts are written below "
1674
+ "(default: the board.yaml's directory, else the current directory).",
1675
+ ),
1676
+ sdk_root: str = typer.Option(
1677
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1678
+ ),
1679
+ board_yaml: str = typer.Option(
1680
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
1681
+ ),
1682
+ project: str = typer.Option(
1683
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1684
+ ),
1685
+ board: str = typer.Option(
1686
+ None,
1687
+ "--board",
1688
+ metavar="TARGET",
1689
+ help="Build a plain Zephyr example that has no board.yaml (alp-sdk's bench "
1690
+ "examples) as one Zephyr slice for this board target, e.g. "
1691
+ "`alp_e1m_aen803_m55_he/ae822fa0e5597ls0/rtss_he`. The app is `--project` "
1692
+ "(default '.'); west, toolchain, SDK and host-Python resolve as for a planned "
1693
+ "build (tan-cli#1359).",
1694
+ ),
1695
+ define: list[str] = typer.Option(
1696
+ None,
1697
+ "-D",
1698
+ "--define",
1699
+ metavar="NAME=VALUE",
1700
+ help="A CMake definition passed after `--` to west on every Zephyr slice "
1701
+ "(or those named by --core), repeatable (`-D SHIELD=...`, `-D CONFIG_X=y`). "
1702
+ "Applied AFTER the plan's own args so it overrides them, except "
1703
+ "EXTRA_CONF_FILE / EXTRA_DTC_OVERLAY_FILE, which are appended `;`-joined to "
1704
+ "tan's list; BOARD and Python3_EXECUTABLE are refused (tan-cli#1382).",
1705
+ ),
1706
+ core: list[str] = typer.Option(
1707
+ None,
1708
+ "--core",
1709
+ metavar="ID",
1710
+ help="Scope -D to the Zephyr slice(s) with this coreId, repeatable "
1711
+ "(default: every Zephyr slice).",
1712
+ ),
1713
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1714
+ pristine: bool = typer.Option(
1715
+ False,
1716
+ "--pristine",
1717
+ help="Force-wipe every slice's build dir before dispatch, regardless of the "
1718
+ "recorded SDK-switch stamp (tan-cli#163) -- the manual counterpart to the "
1719
+ "automatic sdk-switch-pristine wipe, for a stale build dir the stamp heuristic "
1720
+ "doesn't (or can't yet) catch. Same wipe, same two safety guards (an explicit "
1721
+ "`-d`/`--build-dir` in the slice's own command, or a plan cwd outside `build/`): "
1722
+ "this never touches a dir tan can't vouch for. A slice the wipe declines -- for "
1723
+ "either guard, or because the dir was never configured -- says so on the "
1724
+ "envelope and in text (`build.pristine-skipped`), so \"pristine\" never "
1725
+ "silently means \"incremental\".",
1726
+ ),
1727
+ # --- retired: parsed (never a Click typo error) but always refused, ------
1728
+ # naming the replacement in the message itself (tan-cli#427). NOT
1729
+ # `inert_help`-marked: unlike the accept-and-drop global flags, these are
1730
+ # READ for real -- their value selects a specific refusal, not "accepted
1731
+ # and does nothing" (see `_RETIRED_FLAGS`).
1732
+ plan: bool = typer.Option(
1733
+ False, "--plan", help="Retired -- superseded by `--plan-from` (tan-cli#427)."
1734
+ ),
1735
+ manifest: bool = typer.Option(
1736
+ False,
1737
+ "--manifest",
1738
+ help="Retired -- a native `tan build` already writes `build/system-manifest.yaml` "
1739
+ "(tan-cli#427).",
1740
+ ),
1741
+ manifest_from: str = typer.Option(
1742
+ None,
1743
+ "--manifest-from",
1744
+ metavar="FILE",
1745
+ help="Retired -- open FILE directly, it is plain YAML (tan-cli#427).",
1746
+ ),
1747
+ # A real, working flag of the v0.4.1 oracle, retired here rather than
1748
+ # implemented (tan-cli#427) -- `tan build` has no implicit bootstrap for it
1749
+ # to switch off. LISTED in `--help` rather than hidden, for the same reason
1750
+ # the three above are: a reader comparing this surface to v0.4.1's needs to
1751
+ # see that tan knows the flag and is refusing it, which absence cannot say.
1752
+ no_auto_bootstrap: bool = typer.Option(
1753
+ False,
1754
+ "--no-auto-bootstrap",
1755
+ help="Retired -- `tan build` never bootstraps implicitly (tan-cli#427).",
1756
+ ),
1757
+ ) -> None:
1758
+ """Build every slice of the project's build plan."""
1759
+ json_mode = output_format == "json"
1760
+
1761
+ # Before anything is resolved or read: a run naming a retired or deferred
1762
+ # flag does no work at all, and says which flag and why. Retired flags
1763
+ # first (`_RETIRED_FLAGS` already teaches the replacement in its own
1764
+ # message), then the one still-deferred flag -- same precedence reasoning
1765
+ # as before: a flag this build cannot honour in any combination gets the
1766
+ # accurate answer ahead of the `--execute`/`--materialise` conflict check
1767
+ # below, rather than a conflict message for a flag that would not have
1768
+ # worked anyway.
1769
+ retired_given = {
1770
+ "--plan": plan,
1771
+ "--manifest": manifest,
1772
+ "--manifest-from": manifest_from is not None,
1773
+ "--no-auto-bootstrap": no_auto_bootstrap,
1774
+ }
1775
+ for flag, was_given in retired_given.items():
1776
+ if was_given:
1777
+ _refuse(
1778
+ "build.flag-retired",
1779
+ _RETIRED_FLAGS[flag],
1780
+ ExitCode.VALIDATION_FAILURE,
1781
+ json_mode,
1782
+ )
1783
+
1784
+ if execute and materialise:
1785
+ # Two different answers to "what does this run leave behind" -- one
1786
+ # stops after writing, one goes on to dispatch. Refused with its own
1787
+ # code at exit 2 (this CLI's position for an invalid invocation, the
1788
+ # same `--format bogus` takes) instead of letting either win quietly:
1789
+ # a silent precedence win here is precisely what `--execute` exists to
1790
+ # replace.
1791
+ _refuse(
1792
+ "build.conflicting-flags",
1793
+ "`--execute` and `--materialise` cannot be combined: `--materialise` "
1794
+ "writes the plan's files and stops, `--execute` writes them and then "
1795
+ "runs each slice. Pick one (`--execute` already implies writing).",
1796
+ ExitCode.VALIDATION_FAILURE,
1797
+ json_mode,
1798
+ )
1799
+
1800
+ # The oracle's own precedence, measured, reproduced exactly -- `--native`
1801
+ # included: `--plan-from` implies `--plan`, and that implied `--plan` beats
1802
+ # an explicit `--native`, so `--plan-from --native` shows the plan and
1803
+ # writes nothing here too. `--execute` is the port's ADDED capability (see
1804
+ # the module docstring) and the only thing that dispatches a file-supplied
1805
+ # plan; it selects the same `_MODE_NATIVE` a plain `tan build` runs, so it
1806
+ # reports the same `data` rather than a shape of its own. With no
1807
+ # `--plan-from` it is a no-op -- that run already builds.
1808
+ if execute:
1809
+ mode = _MODE_NATIVE
1810
+ elif materialise:
1811
+ mode = _MODE_MATERIALISE
1812
+ elif plan_from is not None:
1813
+ mode = _MODE_PLAN
1814
+ else:
1815
+ mode = _MODE_NATIVE
1816
+
1817
+ # `--pristine`'s own help text promises "never silently means incremental"
1818
+ # -- but `_build` only ever threads `pristine` into `_dispatch` (see its
1819
+ # tail), and `_MODE_PLAN`/`_MODE_MATERIALISE` both return from `_build`
1820
+ # BEFORE `_dispatch` is reached (the former shows the plan and stops, the
1821
+ # latter writes config files and stops -- neither runs a slice, so neither
1822
+ # has a build dir to wipe). Left unchecked, `--pristine --plan-from p.json`
1823
+ # or `--pristine --materialise ...` used to exit 0 with `issues: []`,
1824
+ # having silently done nothing with the flag -- precisely the "declared
1825
+ # and does nothing" shape `--no-auto-bootstrap` above is refused for, and
1826
+ # the closed-issue tan-cli#183 defect class re-entering through the mode
1827
+ # flags instead of the flag that issue was about. Refused with the same
1828
+ # code and exit position `--execute --materialise` already uses for an
1829
+ # invalid combination, rather than added as a fourth mode-specific `data`
1830
+ # shape: `--pristine` has no meaning without a dispatch to apply it to, so
1831
+ # this is a conflicting-flags case, not a new envelope shape to design.
1832
+ # `--execute` is exempt: it selects `_MODE_NATIVE` (line 1918 above) and
1833
+ # dispatches for real, so `--pristine --plan-from p.json --execute` reaches
1834
+ # `_dispatch` and wipes exactly as `--pristine` alone does.
1835
+ if pristine and mode != _MODE_NATIVE:
1836
+ stops_after = (
1837
+ "writes the plan's files"
1838
+ if mode == _MODE_MATERIALISE
1839
+ else "shows the plan"
1840
+ )
1841
+ flag = "--materialise" if mode == _MODE_MATERIALISE else "--plan-from"
1842
+ _refuse(
1843
+ "build.conflicting-flags",
1844
+ f"`--pristine` has nothing to wipe without a dispatch: `{flag}` alone "
1845
+ f"only {stops_after} and stops before any slice runs. Add `--execute` "
1846
+ "to dispatch it for real, or drop `--pristine`.",
1847
+ ExitCode.VALIDATION_FAILURE,
1848
+ json_mode,
1849
+ )
1850
+
1851
+ if core and not define:
1852
+ _refuse(
1853
+ "build.invalid-argument",
1854
+ "`--core` only scopes `-D`; pass `-D NAME=VALUE` with it.",
1855
+ ExitCode.VALIDATION_FAILURE,
1856
+ json_mode,
1857
+ )
1858
+ plain_plan_text: str | None = None
1859
+ # The raw flag, kept before `sdk_root` is reassigned to the RESOLVED root (None
1860
+ # for a rejected flag) so the refusal below can name what the caller typed.
1861
+ sdk_root_flag = sdk_root
1862
+ if board is not None or define:
1863
+ _refuse_plain_route(board, define, board_yaml, plan_from, mode, json_mode)
1864
+
1865
+ # `util::cli_workspace_root`: `--project` joined to the (real) cwd, THEN
1866
+ # everything below anchors on this instead of the bare cwd -- board.yaml
1867
+ # discovery, the default build root, and SDK discovery. Was previously
1868
+ # missing entirely (this command had no `--project` at all): the extension
1869
+ # falls back to it when no project is active in the workspace
1870
+ # (`alp-sdk-vscode/src/west.ts`'s `alpBuild`, `--project <example> build`).
1871
+ # A Typer option Typer does not declare is a Click parse error (exit 2),
1872
+ # so the missing flag REFUSED outright rather than building silently; the
1873
+ # actual wrong-project risk is the relative `--board-yaml` anchor fixed
1874
+ # below. `project is None` (the overwhelming common case, no flag given)
1875
+ # makes `workspace_root == cwd`, byte-for-byte the prior behaviour -- this
1876
+ # only changes anything when `--project` is actually given.
1877
+ #
1878
+ # tan-cli#488 defect 8: safe defaults for every name the exception handler
1879
+ # below and the final `emit()` read, so a raise from ANYWHERE in this
1880
+ # resolution prologue -- not only inside `_build` -- still produces the
1881
+ # `build.internal-failure` envelope the guard below promises, instead of a
1882
+ # raw traceback with empty stdout. `Path.cwd()` immediately below is the
1883
+ # concrete case: a working directory deleted out from under the process
1884
+ # throws `FileNotFoundError` -- previously from OUTSIDE the `try`/`except`,
1885
+ # before `project`/`sdk`/`sdk_tier` had a real value, so it unwound through
1886
+ # typer's own traceback renderer instead of this command's error contract.
1887
+ # Mirrors `doctor_cmd.doctor`'s identical fix for the same defect class.
1888
+ project_envelope = Project(root=None, board_yaml=None)
1889
+ sdk: SdkInfo | None = None
1890
+ sdk_tier = "none"
1891
+ sdk_broken_pin: str | None = None
1892
+ sdk_foreign_default: str | None = None
1893
+ sdk_divergence: Issue | None = None
1894
+ try:
1895
+ cwd = Path.cwd()
1896
+ workspace_root = cwd if project is None else Path(os.path.join(str(cwd), project))
1897
+
1898
+ # Anchor an EXPLICIT `--board-yaml` on `workspace_root`, not the real cwd,
1899
+ # before anything derives from it (the default build root below included).
1900
+ # `_abs_posix` is purely lexical (`os.path.abspath`, which anchors on
1901
+ # `os.getcwd()`), so a relative `--board-yaml` left untouched re-anchors on
1902
+ # the real cwd instead -- matching Rust's `resolve_board_yaml_path`
1903
+ # (`crates/tan-core/src/project.rs:198-208`, which joins a relative
1904
+ # configured path onto `workspace_root`). Verified against the oracle in a
1905
+ # scratch tree (`tmp/app/board.yaml`, cwd=`tmp`): `tan --project app build
1906
+ # --board-yaml board.yaml --format json` must report `project.root` /
1907
+ # `project.boardYaml` under `tmp/app`, not `tmp` -- with a board.yaml also
1908
+ # sitting in the real cwd, the pre-fix anchor planned and built the WRONG
1909
+ # project without a word, exactly the failure class this port exists to
1910
+ # remove.
1911
+ if board_yaml is not None and not os.path.isabs(board_yaml):
1912
+ board_yaml = os.path.join(str(workspace_root), board_yaml)
1913
+
1914
+ # Resolution, in one place, before anything can fail -- and to ABSOLUTE,
1915
+ # BOTH sides, from the same anchor.
1916
+ #
1917
+ # The divergence guard in `apply_plan_token_substitution` compares these
1918
+ # two lexically (as the Rust oracle does), so they must move together: this
1919
+ # resolves both or neither, never one. But they must also both be absolute,
1920
+ # because `${PROJECT_ROOT}` is substituted from `board_yaml` and then
1921
+ # CONSUMED from a different directory -- each slice runs its command in its
1922
+ # own `command.cwd` (`<root>/build/<slice>`), and Zephyr resolves
1923
+ # `-DEXTRA_CONF_FILE` against the APPLICATION source dir, which for the
1924
+ # stock-shim slice is inside the SDK checkout entirely. Left relative, the
1925
+ # default `cd <project> && tan build` resolves `${PROJECT_ROOT}` to `"."`
1926
+ # and every zephyr slice dies -- `<sdk>/firmware/alp-stock-shim/./build/
1927
+ # <slice>/alp.conf: File not found`, and `ERROR: . doesn't contain a
1928
+ # CMakeLists.txt` -- which is the whole documented happy path. Rust never
1929
+ # hits this because it anchors first: `resolve_cli_project_context_inner`
1930
+ # builds `workspace_root = normalize_path(cwd.join(--project))` and derives
1931
+ # `board_yaml_path` by joining onto THAT, so both are absolute before the
1932
+ # guard ever runs (`crates/tan-cli/src/util.rs`, `crates/tan-core/src/
1933
+ # project.rs::resolve_board_yaml_path`).
1934
+ if board_yaml is None and (workspace_root / "board.yaml").is_file():
1935
+ # Already absolute (anchored on `workspace_root`, not a bare
1936
+ # relative "board.yaml") so `_abs_posix` below is a no-op
1937
+ # normalisation rather than a re-anchor onto the real cwd -- the
1938
+ # divergence that would reappear the moment `--project` differs
1939
+ # from cwd.
1940
+ board_yaml = str(workspace_root / "board.yaml")
1941
+ if board is not None:
1942
+ raise BuildError(
1943
+ "build.conflicting-flags",
1944
+ f"`--board` builds a project WITHOUT a board.yaml, but `{board_yaml}` "
1945
+ "exists -- drop `--board` to build it as a planned project.",
1946
+ ExitCode.VALIDATION_FAILURE,
1947
+ )
1948
+ if build_root is None:
1949
+ build_root = str(Path(board_yaml).parent) if board_yaml else str(workspace_root)
1950
+ build_root = _abs_posix(build_root)
1951
+ if board_yaml is not None:
1952
+ board_yaml = _abs_posix(board_yaml)
1953
+
1954
+ # `--sdk-root` > the project's own `.alp/sdk-path` pin > the machine-global
1955
+ # default > the positional walk (`resolve_sdk_root_ladder`, above) --
1956
+ # previously this skipped straight from `--sdk-root` to the positional
1957
+ # walk, so `tan init`'s own pointer went unread the moment `tan build` ran
1958
+ # in the same directory.
1959
+ sdk_resolution = resolve_sdk_root_ladder(sdk_root, workspace_root)
1960
+ resolved_sdk_root = sdk_resolution.path
1961
+ sdk_tier = sdk_resolution.tier
1962
+ sdk_broken_pin = sdk_resolution.broken_project_pin
1963
+ sdk_foreign_default = sdk_resolution.foreign_global_default_for
1964
+ # tan-cli#407: computed HERE, while `sdk_root` still holds the raw
1965
+ # `--sdk-root` flag -- it is reassigned to the RESOLVED root a few lines
1966
+ # below, and a bogus flag is blanked to `None` in between, which would
1967
+ # make this warn about a discovery collision the user never reached
1968
+ # because they named a root explicitly.
1969
+ sdk_divergence = sdk_ladder_divergence_issue(sdk_root, workspace_root, wide=False)
1970
+ # tan-cli#257/#258: `resolve_sdk_root_ladder` returns an explicit
1971
+ # `--sdk-root` UNVALIDATED (I-31 terminal-for-REPORTING, matching the
1972
+ # oracle's `resolve_sdk_tiered`) -- fine for a caller that only reports
1973
+ # the tier, but `build` also ACTS on `resolved_sdk_root`, so a bogus flag
1974
+ # used to sail through as `sdk.sourceTier: "sdkRootFlag"`, reach
1975
+ # `_emit_plan` as a non-None `sdk_root`, and get refused for the NEXT
1976
+ # missing thing (`no board.yaml found`) instead -- telling the customer
1977
+ # their project is broken when the `--sdk-root` they just typed is what's
1978
+ # wrong, and reporting an `sdk` key the oracle never emits on this path.
1979
+ # Validated here, at the flag's own entry point, rather than in the
1980
+ # shared ladder (which every other caller also relies on staying
1981
+ # unvalidated) -- same shape as `clean_cmd.sdk_root_resolves` and
1982
+ # `flash_cmd._resolve_sdk`, the two callers that already guard their own
1983
+ # explicit `--sdk-root`. An unresolvable explicit root is treated as no
1984
+ # root at all: `_emit_plan` then gives its own "no alp-sdk checkout
1985
+ # found" refusal, and no `sdk` key is reported, matching the oracle.
1986
+ if sdk_tier == "sdkRootFlag" and not is_sdk_root(resolved_sdk_root):
1987
+ resolved_sdk_root = None
1988
+ sdk_root = str(resolved_sdk_root) if resolved_sdk_root is not None else None
1989
+ sdk = SdkInfo(sdk_root, sdk_tier) if sdk_root is not None else None
1990
+ if board is not None:
1991
+ plain_plan_text = plain_zephyr_plan(
1992
+ _abs_posix(str(workspace_root)),
1993
+ board,
1994
+ normalise_defines(define or [])[0],
1995
+ core_id=_plain_core_id(board, sdk_root),
1996
+ )
1997
+ # Absolute, `.`/`..`-collapsed, anchored on `workspace_root` -- what the
1998
+ # sdk-switch-pristine guard actually compares (tan-cli#163), kept
1999
+ # SEPARATE from `sdk_root` itself: an explicit `--sdk-root` is a
2000
+ # documented, supported relative form, and `sdk_root` unchanged still
2001
+ # feeds `${SDK_ROOT}` token substitution and the `sdk.root` envelope
2002
+ # field verbatim, matching the Rust oracle's own split between
2003
+ # `normalized_sdk_root_str` (stamp-only) and the raw `resolve_sdk_root`
2004
+ # result used everywhere else.
2005
+ sdk_root_for_stamp = (
2006
+ str(normalize_path(workspace_root / sdk_root)) if sdk_root is not None else None
2007
+ )
2008
+ # tan-cli#236: `boardYaml` reported only when the file really exists --
2009
+ # `board_yaml` itself stays unfiltered for `_build` below, which needs the
2010
+ # unconditional path.
2011
+ project_envelope = Project.resolved(build_root, board_yaml)
2012
+
2013
+ # tan-cli#287: the heartbeat that covers a silent slice (a first-build
2014
+ # CMake configure, measured at 234.2s with nothing printed) is armed
2015
+ # inside `_dispatch`, not here -- `_build`'s OTHER caller,
2016
+ # `run_cmd._run`, bottlenecks through the identical
2017
+ # `_dispatch` call and needs the same cover for a silent `tan run`. See
2018
+ # `_dispatch`'s own docstring for the TTY/`json_mode` gating (unchanged:
2019
+ # still never armed for `--format json`, stdout is untouched either way)
2020
+ # and for the mode-gating this also fixes for free -- `_MODE_PLAN`/
2021
+ # `_MODE_MATERIALISE` return from `_build` before `_dispatch` is ever
2022
+ # reached, so neither ticks a heartbeat for a slow in-process planner
2023
+ # that never runs CMake at all.
2024
+ exit_code, data, issues = _build(
2025
+ mode=mode,
2026
+ plan_from=plan_from,
2027
+ build_root=build_root,
2028
+ sdk_root=sdk_root,
2029
+ sdk_root_for_stamp=sdk_root_for_stamp,
2030
+ board_yaml=board_yaml,
2031
+ json_mode=json_mode,
2032
+ pristine=pristine,
2033
+ plain_plan_text=plain_plan_text,
2034
+ defines=normalise_defines(define or [])[0],
2035
+ define_cores=list(core) if core else None,
2036
+ )
2037
+ except BuildError as err:
2038
+ message = err.message
2039
+ if err.code == "build.sdk-root-unresolved":
2040
+ # tan-cli#1423: where the ladder looked, beside the message's own remedy.
2041
+ if (sdk_root_flag or "").strip():
2042
+ # `--sdk-root` is terminal (I-31): no other tier was tried, so
2043
+ # name the rejected value instead of recommending the flag or
2044
+ # claiming the ladder was searched (tan-cli#1521).
2045
+ message = rejected_sdk_root_message(sdk_root_flag, "Nothing was built.")
2046
+ else:
2047
+ message = with_sdk_search(message, workspace_root)
2048
+ exit_code, data, issues = err.exit_code, None, [Issue(err.code, "error", message)]
2049
+ except Exception as err: # noqa: BLE001 -- see below
2050
+ # The port's most-repeated defect class: an uncaught exception
2051
+ # escapes as a raw traceback, stdout stays empty, and the
2052
+ # extension renders nothing at all with no error on either side.
2053
+ # Anything that reaches here is a tan bug, so it is reported as
2054
+ # one -- with an envelope.
2055
+ exit_code = ExitCode.INTERNAL_FAILURE
2056
+ data = None
2057
+ issues = [Issue("build.internal-failure", "error", f"{type(err).__name__}: {err}")]
2058
+
2059
+ # These facts are all about how the SDK ROOT was RESOLVED, so all are
2060
+ # prepended ahead of whatever the build itself reported, and all survive a
2061
+ # run that never planned anything -- a refusal is exactly when knowing
2062
+ # which checkout answered matters most.
2063
+ #
2064
+ # tan-cli#263 review: `tan build` is the command a silently-missed
2065
+ # `.alp/sdk-path` pin hurts most -- it builds against whichever tier the
2066
+ # ladder fell through to and says nothing, where `sdk current` alone only
2067
+ # helps someone already suspicious enough to run it. tan-cli#407 is the
2068
+ # same argument for the two-checkout collision one tier below it, and
2069
+ # tan-cli#464 the same again for a `globalDefault` answer a DIFFERENT
2070
+ # project's bootstrap relocation actually decided -- `sdk current` was the
2071
+ # only place this warning reached before; `tan build` is the one that
2072
+ # silently compiles against the wrong checkout because of it.
2073
+ resolution_issues = [
2074
+ issue
2075
+ for issue in (
2076
+ project_pin_issue(sdk_broken_pin, sdk_tier),
2077
+ sdk_divergence,
2078
+ global_default_foreign_project_issue(sdk_foreign_default),
2079
+ )
2080
+ if issue is not None
2081
+ ]
2082
+ if resolution_issues:
2083
+ issues = [*resolution_issues, *issues]
2084
+
2085
+ if json_mode:
2086
+ emit(Envelope("build", project_envelope, data, issues, exit_code, sdk=sdk))
2087
+ else:
2088
+ _print_text_issues(issues, data)
2089
+ _text_recap(mode, data)
2090
+ raise typer.Exit(int(exit_code))
2091
+
2092
+
2093
+ def _print_text_issues(issues: list[Issue], data: dict | None) -> None:
2094
+ """Print the `issues[]` block of the text recap, minus any line the
2095
+ per-slice recap below is about to repeat verbatim (tan-cli#746).
2096
+
2097
+ Dedup and printing live in ONE function on purpose: a `_text_issues`
2098
+ that only FILTERED could be dropped from the call site and every test of
2099
+ it would stay green -- measured, that is exactly what happened (6 of 6
2100
+ passing with the call removed). Keeping the print here means a test that
2101
+ captures this function's output covers both halves.
2102
+ """
2103
+ for issue in _text_issues(issues, data):
2104
+ print(f"{issue.severity}: {issue.message}", file=sys.stderr)
2105
+
2106
+
2107
+ def _text_issues(issues: list[Issue], data: dict | None) -> list[Issue]:
2108
+ """Drop an `issues[]` line whose text the per-slice recap is about to
2109
+ print verbatim anyway (tan-cli#746).
2110
+
2111
+ TEXT MODE ONLY -- the JSON envelope keeps every issue untouched. Two
2112
+ deliberate features collide here and neither is wrong on its own:
2113
+ `_missing_tool_issues` promotes a skip's reason into `issues[]` so a
2114
+ JSON consumer sees it (tan-cli#283), and that reason carries the literal
2115
+ searched-PATH list so a customer can fix it themselves (tan-cli#510).
2116
+ In `--format json` those are two different fields. In text they are two
2117
+ adjacent lines carrying the same string.
2118
+
2119
+ Measured on a Windows host with no `bitbake`, where the AEN801 SoM's
2120
+ `a32_cluster` yocto slice is an EXPECTED skip: 2807 + 2801 = 5608
2121
+ characters, twice the same 57-entry PATH, pushing the two `ok:` lines
2122
+ and the `2 of 3 slice(s) built` summary below it.
2123
+
2124
+ Matched on the reason text, not on the issue code: `_missing_tool_issues`
2125
+ is not the only producer that may wrap a slice reason, and a code list
2126
+ here would silently stop covering the next one. An issue whose message
2127
+ is NOT already on a slice line is always kept -- this only removes a
2128
+ second copy, never information.
2129
+ """
2130
+ if not data:
2131
+ return issues
2132
+ reasons = {
2133
+ result["reason"]
2134
+ for result in (data.get("slices") or [])
2135
+ if isinstance(result, dict) and result.get("reason")
2136
+ }
2137
+ if not reasons:
2138
+ return issues
2139
+ return [
2140
+ issue
2141
+ for issue in issues
2142
+ if not any(
2143
+ reason in issue.message and issue.message.endswith(reason)
2144
+ for reason in reasons
2145
+ )
2146
+ ]
2147
+
2148
+
2149
+ def _text_recap(mode: str, data: dict | None) -> None:
2150
+ """The stderr recap for whichever mode ran. Three shapes because `data`
2151
+ has three shapes -- a plan-mode `data.slices[]` entry is a PLAN slice with
2152
+ no `status` at all, so the build recap below would `KeyError` on it."""
2153
+ if data is None:
2154
+ return
2155
+ if mode == _MODE_MATERIALISE:
2156
+ print(
2157
+ f"materialised {len(data['written'])} file(s) under {data['baseDir']}:",
2158
+ file=sys.stderr,
2159
+ )
2160
+ for rel in data["written"]:
2161
+ print(f" {rel}", file=sys.stderr)
2162
+ return
2163
+ if mode == _MODE_PLAN:
2164
+ print(
2165
+ f"build plan (schema v{data.get('schemaVersion')}) -- {data.get('sku')}",
2166
+ file=sys.stderr,
2167
+ )
2168
+ print(f" board.yaml: {data.get('boardYaml')}", file=sys.stderr)
2169
+ print(f" build root: {data.get('buildRoot')}", file=sys.stderr)
2170
+ slices = data.get("slices") or []
2171
+ print(f" slices ({len(slices)}):", file=sys.stderr)
2172
+ for sl in slices:
2173
+ print(
2174
+ f" - {sl.get('coreId')} [{sl.get('backend')}] -> {sl.get('buildDir')}",
2175
+ file=sys.stderr,
2176
+ )
2177
+ print(
2178
+ f" shared artefacts: {len(data.get('sharedArtefacts') or [])}",
2179
+ file=sys.stderr,
2180
+ )
2181
+ # tan-cli#1000, oracle parity: the retired Rust `summarize_plan`
2182
+ # (`crates/tan-core/src/build_plan.rs:358`, read at `2883cdf^`)
2183
+ # printed `warnings: 0` when empty and otherwise expanded every
2184
+ # entry as ` - [{code}] {coreId}: {message}`. The port counted them,
2185
+ # so this surface lost the same cause the ordinary build path did.
2186
+ plan_warnings = data.get("warnings") or []
2187
+ if not plan_warnings:
2188
+ print(" warnings: 0", file=sys.stderr)
2189
+ else:
2190
+ print(f" warnings ({len(plan_warnings)}):", file=sys.stderr)
2191
+ for warning in plan_warnings:
2192
+ core_id = warning.get("coreId")
2193
+ where = f" {core_id}:" if core_id else ""
2194
+ print(
2195
+ f" - [{warning.get('code')}]{where} {warning.get('message')}",
2196
+ file=sys.stderr,
2197
+ )
2198
+ return
2199
+ slices = data.get("slices", [])
2200
+ for result in slices:
2201
+ reason = f" -- {result['reason']}" if "reason" in result else ""
2202
+ # tan-cli#510 review, MAJOR 1: shown for a failed/cancelled slice
2203
+ # only (`missingTool`'s own refusal already names what it searched
2204
+ # in `reason` above, unaffected by this) -- a success needs nothing
2205
+ # more explained, and `resolvedTool` is already `null` whenever it
2206
+ # would just echo back the plan's own `tool` identity (see
2207
+ # `SliceOutcome.resolved_tool`'s docstring). Deliberately NOT folded
2208
+ # into `reason` itself -- see `_slice_result`'s own docstring for why.
2209
+ resolved_tool = result.get("resolvedTool")
2210
+ resolved_note = (
2211
+ f" (resolved to `{resolved_tool}`)"
2212
+ if result["status"] == "failed" and resolved_tool
2213
+ else ""
2214
+ )
2215
+ print(
2216
+ f"{result['status']}: {result['coreId']} [{result['backend']}]{reason}{resolved_note}",
2217
+ file=sys.stderr,
2218
+ )
2219
+ # tan-cli#283: a one-line summary AFTER every per-slice line, so a
2220
+ # partial or all-skipped build cannot be scrolled past -- the per-slice
2221
+ # lines above already carry this same count, but only for a reader who
2222
+ # counts them by hand.
2223
+ if slices:
2224
+ built = sum(1 for result in slices if result["status"] == "ok")
2225
+ print(f"{built} of {len(slices)} slice(s) built", file=sys.stderr)
2226
+
2227
+
2228
+ # tan-cli#427: `--target`/`--all`/`--verbose`/`--quiet`/`--no-color`/
2229
+ # `--non-interactive`/`--ci` are accept-and-drop here, same as on every other
2230
+ # command `accept_global_flags` already covers -- the oracle's own `cli.rs`
2231
+ # declares all seven ONLY on the shared `GlobalArgs` struct (`BuildArgs` has
2232
+ # no fields for any of them), so `build`'s Rust handler never reads them
2233
+ # either. `--project`/`--board-yaml`/`--sdk-root` are already declared above
2234
+ # for real, so this adds nothing for those three (`_declared_flags` skips a
2235
+ # flag `build` already reads).
2236
+ build = accept_global_flags(build)