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,1092 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Build-plan emission -- the Wave C consumer contract.
4
+
5
+ `emit_build_plan` renders the machine-readable JSON build plan `tan`
6
+ (alplabai/tan-cli) materialises; the shared helpers `_slice_build_dir` /
7
+ `_slice_config_artefact` / `_shared_artefacts` are the single source the
8
+ Orchestrator's materialise path and the plan MUST agree on byte-for-byte
9
+ (tan reads what the Orchestrator writes).
10
+ Extracted as the #285 build-plan emit seam. The per-slice config emitters come
11
+ from kconfig.py, the header/secure artefacts from headers.py / secure.py; the
12
+ orchestrator-side slice-command bits (_slice_command, STOCK_SHIM_APP) are
13
+ lazy-imported from the package (they stay inline until orchestrator.py).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ from dataclasses import replace
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Optional
25
+
26
+ from tan.core.subprocess_env import spawn_env
27
+ from tan.core.tool_lookup import resolve_tool
28
+
29
+ from .headers import (
30
+ emit_dts_partitions,
31
+ emit_dts_reservations,
32
+ emit_ipc_contract_h,
33
+ emit_storage_mounts_c,
34
+ has_storage_mounts,
35
+ )
36
+ from .kconfig import (
37
+ _resolve_console,
38
+ _slice_alp_conf,
39
+ _slice_cmake_args,
40
+ _slice_local_conf,
41
+ )
42
+ from .link_target import extra_config_artefacts
43
+ from .models import BoardProject, OrchestratorError, Slice
44
+ from .ownership import project_m33_overlay
45
+ from .paths import REPO
46
+ from .project_emit.dts import DtsOverlayUnavailable # noqa: F401 (tan adaptation: defined beside the emitter that raises it)
47
+ from .secure import emit_sysbuild_conf, emit_tfm_sysbuild_conf
48
+
49
+ # The skip-vs-fail policy a slice dispatcher MUST apply, published verbatim
50
+ # in the plan envelope so a consumer (tan-cli) stops hand-porting it: an
51
+ # unknown `backend` fails the slice, a missing tool on PATH or a null
52
+ # `command` skip it.
53
+ _EXECUTION_POLICY = {
54
+ "unknownBackend": "fail",
55
+ "missingTool": "skip",
56
+ "nullCommand": "skip",
57
+ }
58
+
59
+ # The `${NAME}` tokens a plan consumer substitutes in a `planPathMode:
60
+ # tokened` plan. This emitter writes the first three; tan-cli also resolves
61
+ # TOOLCHAIN_ROOT, so a board.yaml placeholder must not reuse that name either.
62
+ PLAN_PATH_TOKENS = frozenset({"SDK_ROOT", "PROJECT_ROOT", "PYTHON", "TOOLCHAIN_ROOT"})
63
+
64
+ # Any `${...}` in a plan field, and the names `deferredPlaceholders` may carry
65
+ # (the schema's pattern; tan-cli matches the same).
66
+ _PLAN_REF_RE = re.compile(r"\$\{([^}]*)\}")
67
+ _DEFERRED_NAME_RE = re.compile(r"[A-Z][A-Z0-9_]*")
68
+
69
+
70
+ def _deferred_placeholders(
71
+ slices_out: list[dict[str, Any]],
72
+ shared: list[dict[str, str]],
73
+ board_yaml: Path,
74
+ ) -> list[str]:
75
+ """Names of the `${NAME}` placeholders this plan leaves unresolved on
76
+ purpose (issue #2696), sorted.
77
+
78
+ A deferred placeholder is a value the user wrote in board.yaml as
79
+ `${NAME}` and the planner copied verbatim into a slice config artefact,
80
+ for the build host or the device to fill
81
+ (`examples/connectivity/iot-fleet-ota`'s `ota.server.tenant`). Who fills
82
+ it, and how, is outside the plan; the list only says the consumer must
83
+ leave it alone. Listing
84
+ them lets a consumer tell them from a plan path token it must substitute,
85
+ instead of refusing the whole plan as an unresolved token.
86
+
87
+ Every other `${...}` is refused here, because a consumer cannot handle it
88
+ correctly:
89
+ * a plan path token in a config artefact -- a user value named like
90
+ `${SDK_ROOT}` would be silently replaced by a checkout path;
91
+ * a name not written as `${NAME}` in the project's board.yaml -- the
92
+ planner invented it, and tan would refuse it anyway;
93
+ * a name outside `[A-Z][A-Z0-9_]*`, the contract's name pattern;
94
+ * a placeholder in an artefact that is not a `.conf` file -- CMake
95
+ would expand it itself (the same rule tan-cli#1306 applies);
96
+ * any non-token `${...}` outside `configArtefacts` (command, env,
97
+ envAppendPath, appDir, postCommands, sharedArtefacts).
98
+ """
99
+ board_text = Path(board_yaml).read_text(encoding="utf-8")
100
+ deferred: set[str] = set()
101
+
102
+ def refuse(where: str, ref: str, why: str) -> None:
103
+ raise OrchestratorError(
104
+ f"build plan: `${{{ref}}}` in {where} {why} (issue #2696)")
105
+
106
+ for slice_ in slices_out:
107
+ core = slice_["coreId"]
108
+ for artefact in slice_["configArtefacts"]:
109
+ where = f"core '{core}' config artefact `{artefact['path']}`"
110
+ for ref in _PLAN_REF_RE.findall(artefact["contents"]):
111
+ if not artefact["path"].endswith(".conf"):
112
+ refuse(where, ref, "is in a non-`.conf` artefact, which "
113
+ "its reader (e.g. CMake) would expand "
114
+ "itself -- a placeholder may only "
115
+ "reach a Kconfig fragment or local.conf")
116
+ if ref in PLAN_PATH_TOKENS:
117
+ refuse(where, ref, "is a plan path token: a consumer would "
118
+ "replace it with a checkout path")
119
+ if not _DEFERRED_NAME_RE.fullmatch(ref):
120
+ refuse(where, ref, "is not a valid placeholder name -- "
121
+ "use upper-case letters, digits and "
122
+ "`_`, starting with a letter")
123
+ if f"${{{ref}}}" not in board_text:
124
+ refuse(where, ref, "does not come from board.yaml, so the "
125
+ "planner produced it -- an unresolved "
126
+ "token")
127
+ deferred.add(ref)
128
+ rest = {k: slice_[k] for k in ("command", "env", "envAppendPath",
129
+ "appDir", "postCommands")}
130
+ for ref in _PLAN_REF_RE.findall(json.dumps(rest)):
131
+ if ref not in PLAN_PATH_TOKENS:
132
+ refuse(f"core '{core}' command/env/appDir", ref,
133
+ "is not a plan path token -- a placeholder may only "
134
+ "appear in a config artefact")
135
+ for artefact in shared:
136
+ for ref in _PLAN_REF_RE.findall(artefact["contents"]):
137
+ refuse(f"shared artefact `{artefact['path']}`", ref,
138
+ "cannot be resolved -- a placeholder may only appear in a "
139
+ "slice config artefact")
140
+ return sorted(deferred)
141
+
142
+
143
+ def _slice_build_dir(build_root: Path, slice_: Slice) -> Path:
144
+ """Per-slice build directory: build/<core>-<os>/."""
145
+ return Path(build_root) / f"{slice_.core_id}-{slice_.os}"
146
+
147
+
148
+ def _slice_config_artefact(
149
+ project: BoardProject,
150
+ slice_: Slice,
151
+ ) -> Optional[tuple[str, str]]:
152
+ """(filename, contents) of the slice's config artefact, or None
153
+ when the os has none.
154
+
155
+ Single source for both a consumer's materialise step and
156
+ `emit_build_plan` -- the two MUST agree byte-for-byte (the CLI
157
+ consumer byte-writes the plan's contents and trusts them to match
158
+ what we'd write ourselves).
159
+
160
+ `baremetal` carries `alp-baremetal.cmake` -- and ONLY when the slice
161
+ actually has compile-time guards to carry (absence-emits-nothing).
162
+ This is NOT the old `cmake-args.txt` coming back: that file was
163
+ removed in 2026-08 (#1278) precisely because no build command ever
164
+ read it, and the test below is exactly the condition it failed --
165
+ `_slice_command`'s baremetal branch pulls this file in with
166
+ `-DCMAKE_PROJECT_INCLUDE=<abs path>`, so a slice that stops writing
167
+ it stops compiling with its `ALP_BOARD_<SLUG>` / `ALP_SOM_<SKU>`
168
+ guards, loudly (alplabai/tan-cli#551). The `=`-bearing cache entries
169
+ from the same source ride the configure command line directly and are
170
+ NOT duplicated here. The full human-readable `-D` listing remains
171
+ also published as the plan's `cmake-args.txt` artefact
172
+ (`_slice_cmake_args_artefact`) -- see docs/board-config-emit.md.
173
+ """
174
+ if slice_.os == "zephyr":
175
+ return ("alp.conf", _slice_alp_conf(project, slice_))
176
+ if slice_.os == "yocto":
177
+ return ("local.conf", _slice_local_conf(project, slice_))
178
+ if slice_.os == "baremetal":
179
+ # Lazy, same buildplan<->orchestrator cycle avoidance
180
+ # `emit_build_plan` uses for the slice-command helpers.
181
+ from .orchestrator import (
182
+ BAREMETAL_PROJECT_INCLUDE,
183
+ _baremetal_project_include,
184
+ )
185
+ contents = _baremetal_project_include(project, slice_)
186
+ if contents is None:
187
+ return None
188
+ return (BAREMETAL_PROJECT_INCLUDE, contents)
189
+ return None
190
+
191
+
192
+ #: The os classes a slice's `alp.overlay` artefact is emitted for: the
193
+ #: classes `alp_project.py --emit dts-overlay` unions over. A yocto slice has
194
+ #: none. `cmake-args.txt` is emitted for the same two (`--emit cmake-args`).
195
+ _DTS_OVERLAY_OS = ("zephyr", "baremetal")
196
+
197
+ #: Filename of the rendered DTS overlay config artefact (under `buildDir`).
198
+ DTS_OVERLAY_ARTEFACT = "alp.overlay"
199
+
200
+ #: Filename of the rendered full `-D` listing config artefact.
201
+ CMAKE_ARGS_ARTEFACT = "cmake-args.txt"
202
+
203
+ #: Filename of the rendered build-identifier header config artefact
204
+ #: (`--emit hw-info-h --core <id>`). Same name `<alp/hw_info.h>` documents.
205
+ HW_INFO_ARTEFACT = "alp_hw_info_build.h"
206
+
207
+ #: Filename of the rendered west manifest fragment config artefact
208
+ #: (`--emit west-libraries --core <id>`).
209
+ WEST_LIBS_ARTEFACT = "alp-west-libs.yml"
210
+
211
+
212
+ class HwInfoUnavailable(OrchestratorError):
213
+ """The slice's SKU is outside the production families, so there is no
214
+ family to put in `ALP_HW_BUILD_SOM_FAMILY`. Downgraded by
215
+ `emit_build_plan` to a `hw-info-unavailable` warning, like
216
+ `DtsOverlayUnavailable`; any other failure still fails the plan."""
217
+
218
+
219
+ def _v1_shaped_project(project: BoardProject) -> dict[str, Any]:
220
+ """The legacy `board:`-wrapper dict the project-wide emitters read.
221
+
222
+ The public board.yaml schema no longer uses this wrapper, but the
223
+ in-file emitters (dts-overlay, hw-info-h, west-libraries) still consume
224
+ it. Shared by `alp_project.py` and `_slice_dts_overlay`.
225
+ """
226
+ return {
227
+ "som": {
228
+ "sku": project.sku,
229
+ "hw_rev": project.hw_rev,
230
+ },
231
+ "pins": list(project.raw.get("pins") or []),
232
+ "board": ({
233
+ "name": project.board_name,
234
+ "hw_rev": project.board_hw_rev,
235
+ } if project.board_name else None),
236
+ }
237
+
238
+
239
+ def _slice_dts_overlay(project: BoardProject, slice_: Slice) -> str:
240
+ """The slice's DTS overlay text -- exactly what
241
+ `alp_project.py --emit dts-overlay --core <id>` prints.
242
+
243
+ Single source for that standalone emit and the build plan's
244
+ `alp.overlay` config artefact (ADR-0026 §D: `tan` consumes these bytes
245
+ instead of re-rendering them). The overlay is shaped by the board
246
+ header (bus aliases + `alp,pin-array`), a SoM-mounting fact; the slice
247
+ contributes its peripheral list and, per-product, the assignable nodes
248
+ board.yaml `ownership:` gave the M33.
249
+ """
250
+ # tan adaptation: the emitter is a sibling subpackage here, not the
251
+ # `alp_project_emit` package beside `alp_orchestrate/` upstream.
252
+ from .project_emit.dts import _emit_dts_overlay
253
+ from .som_metadata import _sku_family
254
+
255
+ # Only the two facts the emitter itself cannot render are "unavailable"
256
+ # (the plan degrades to a warning); everything else propagates.
257
+ try:
258
+ _sku_family(project.sku)
259
+ except ValueError as exc:
260
+ raise DtsOverlayUnavailable(str(exc)) from None
261
+ out = _emit_dts_overlay(
262
+ _v1_shaped_project(project), project.som_preset,
263
+ project.board_preset,
264
+ v2_peripherals=sorted(set(slice_.peripherals)),
265
+ v2_core_id=slice_.core_id,
266
+ v2_core_os=slice_.os,
267
+ v2_core_ids=[slice_.core_id],
268
+ )
269
+ # (tan adaptation: the missing-header case raises `DtsOverlayUnavailable`
270
+ # from the emitter itself rather than `sys.exit`ing.)
271
+ # Outside the degrade path above: an M33 ownership defect must still
272
+ # fail the plan, not turn into a missing artefact.
273
+ own_dts, _ = project_m33_overlay(project, slice_.core_id)
274
+ if own_dts:
275
+ out += ("\n/* Assignable peripherals owned by the M33 "
276
+ "(board.yaml `ownership:`). */\n" + "\n".join(own_dts) + "\n")
277
+ return out
278
+
279
+
280
+ def _slice_dts_overlay_artefact(
281
+ project: BoardProject,
282
+ slice_: Slice,
283
+ ) -> Optional[tuple[str, str]]:
284
+ """(filename, contents) of the slice's rendered DTS overlay, or None for
285
+ an os that has none (see `_DTS_OVERLAY_OS`)."""
286
+ if slice_.os not in _DTS_OVERLAY_OS:
287
+ return None
288
+ return (DTS_OVERLAY_ARTEFACT, _slice_dts_overlay(project, slice_))
289
+
290
+
291
+ def _slice_cmake_args_artefact(
292
+ project: BoardProject,
293
+ slice_: Slice,
294
+ ) -> Optional[tuple[str, str]]:
295
+ """(filename, contents) of the slice's full `-D` listing -- exactly what
296
+ `alp_project.py --emit cmake-args --core <id>` prints after its
297
+ `# --- core ---` marker -- or None for an os that has none.
298
+
299
+ A rendered REFERENCE listing for consumers (ADR-0026 §D). The
300
+ `=`-bearing cache entries already ride the baremetal configure line and
301
+ the bare guards arrive via `alp-baremetal.cmake`; this file is not read
302
+ by any build command and so does not resurrect the dead `cmake-args.txt`
303
+ #1278 removed -- it is the same text, now published in the plan so `tan`
304
+ stops re-rendering it.
305
+ """
306
+ if slice_.os not in _DTS_OVERLAY_OS:
307
+ return None
308
+ return (CMAKE_ARGS_ARTEFACT, _slice_cmake_args(project, slice_))
309
+
310
+
311
+ def _slice_hw_info_h(project: BoardProject, slice_: Slice) -> str:
312
+ """The slice's build-identifier header -- exactly what
313
+ `alp_project.py --emit hw-info-h --core <id>` prints.
314
+
315
+ `ALP_HW_BUILD_CORES` / `HAS_<id>` range over EVERY core of the project
316
+ (`v2_cores`), and `ALP_HW_BUILD_OS` / `PRIMARY_CORE` track the slice
317
+ (`v2_selected_core`). Single source for the standalone emit and the
318
+ build plan's `alp_hw_info_build.h` config artefact.
319
+ """
320
+ # tan adaptation: the emitter is a sibling subpackage here.
321
+ from .project_emit.hw_info import _emit_hw_info_h
322
+
323
+ return _emit_hw_info_h(
324
+ _v1_shaped_project(project), project.som_preset,
325
+ project.board_preset,
326
+ v2_cores={cid: s.os for cid, s in project.cores.items()},
327
+ v2_selected_core=slice_.core_id,
328
+ # Same tree alp.conf's `CONFIG_ALP_SDK_SOM_HW_REV` reads, so the two
329
+ # composed hw_rev designators cannot disagree under --metadata-root.
330
+ metadata_root=project.effective_metadata_root(),
331
+ )
332
+
333
+
334
+ def _slice_west_libraries(project: BoardProject, slice_: Slice) -> str:
335
+ """The slice's west manifest fragment -- exactly what
336
+ `alp_project.py --emit west-libraries --core <id>` prints.
337
+
338
+ Single source for that standalone emit and the build plan's
339
+ `alp-west-libs.yml` config artefact.
340
+ """
341
+ from .project_emit.west_libs import _emit_west_libraries
342
+
343
+ return _emit_west_libraries(
344
+ _v1_shaped_project(project), project.som_preset,
345
+ project.board_preset,
346
+ v2_libraries=sorted(set(slice_.libraries)),
347
+ v2_project_libraries=sorted(project.libraries),
348
+ metadata_root=project.effective_metadata_root(),
349
+ )
350
+
351
+
352
+ def _slice_hw_info_artefact(
353
+ project: BoardProject,
354
+ slice_: Slice,
355
+ ) -> Optional[tuple[str, str]]:
356
+ """(filename, contents) of the slice's rendered `alp_hw_info_build.h`, or
357
+ None for an os that has none (see `_DTS_OVERLAY_OS`).
358
+
359
+ A REFERENCE artefact (ADR-0026 §D), like `cmake-args.txt`: no build
360
+ command, CMake file or Kconfig in this SDK reads it. The firmware's
361
+ own `ALP_HW_BUILD_*` values still come from the build, not this file.
362
+ """
363
+ if slice_.os not in _DTS_OVERLAY_OS:
364
+ return None
365
+ # Only the SKU->family lookup is "unavailable" (same narrow pattern as
366
+ # `_slice_dts_overlay`); a damaged hw-revisions table etc. must still fail
367
+ # the plan, so the render itself runs outside any except.
368
+ from .som_metadata import _sku_family
369
+ try:
370
+ _sku_family(project.sku)
371
+ except ValueError as exc:
372
+ raise HwInfoUnavailable(str(exc)) from None
373
+ return (HW_INFO_ARTEFACT, _slice_hw_info_h(project, slice_))
374
+
375
+
376
+ def _slice_west_libs_artefact(
377
+ project: BoardProject,
378
+ slice_: Slice,
379
+ ) -> Optional[tuple[str, str]]:
380
+ """(filename, contents) of the slice's rendered west fragment, or None
381
+ for an os that has none. Emitted even when the slice selects no
382
+ libraries: the emitter renders a well-formed empty allowlist, and
383
+ byte-identity with `--emit west-libraries --core` is the contract.
384
+ Reference only, like `cmake-args.txt`."""
385
+ if slice_.os not in _DTS_OVERLAY_OS:
386
+ return None
387
+ return (WEST_LIBS_ARTEFACT, _slice_west_libraries(project, slice_))
388
+
389
+
390
+ def _emit_storage_mount_table(project: BoardProject) -> str:
391
+ """The `storage_mount_table.c` contents, or "" when board.yaml `storage:`
392
+ declares no mountable partition. The standalone emit prints an empty
393
+ table for that case, which the plan has no reason to carry, so the
394
+ conditional-artefact rule (empty -> absent) keys off `has_storage_mounts`
395
+ rather than off the text, which is never empty (alp-sdk#2820)."""
396
+ if not has_storage_mounts(project):
397
+ return ""
398
+ return emit_storage_mounts_c(project)
399
+
400
+
401
+ #: Every shared generated artefact: `(path under the build root, emitter,
402
+ #: conditional)`. ONE call site per artefact: `_shared_artefacts` (the plan)
403
+ #: and `_shared_artefact` (the standalone `--emit`) both run the emitter from
404
+ #: THIS table, and the latter runs only the one it was asked for, so one
405
+ #: emitter's refusal (e.g. `emit_sysbuild_conf` on an unsupported `boot:`)
406
+ #: cannot leak into another mode (tan-cli#1216, ADR-0026 §D).
407
+ #:
408
+ #: `<alp/system_ipc.h>` is the canonical include path consumers use (see
409
+ #: include/alp/rpc.h §usage and the per-slice main.c references) -- it sits in
410
+ #: an `alp/` subdir so slice CMakeLists add `generated/` straight to the
411
+ #: include path. Apps that don't declare storage[] still get a stub
412
+ #: `dts-partitions.dtsi` so downstream #include resolves. Conditional
413
+ #: artefacts (sysbuild / TF-M) follow absence-emits-nothing: they only appear
414
+ #: when their emit is non-empty.
415
+ _SHARED_EMITTERS: tuple[tuple[str, Callable[[BoardProject], str], bool], ...] = (
416
+ ("generated/alp/system_ipc.h", emit_ipc_contract_h, False),
417
+ ("generated/dts-reservations.dtsi", emit_dts_reservations, False),
418
+ ("generated/dts-partitions.dtsi", emit_dts_partitions, False),
419
+ ("alp_sysbuild.conf", emit_sysbuild_conf, True),
420
+ ("sysbuild/tfm/tfm.conf", emit_tfm_sysbuild_conf, True),
421
+ ("generated/storage_mount_table.c", _emit_storage_mount_table, True),
422
+ )
423
+
424
+
425
+ def _shared_artefacts(
426
+ project: BoardProject,
427
+ build_root: Path,
428
+ ) -> list[tuple[Path, str]]:
429
+ """(path, contents) of every shared generated artefact.
430
+
431
+ Single source for `_materialise_shared` and `emit_build_plan`
432
+ (same byte-parity contract as `_slice_config_artefact`).
433
+ """
434
+ build_root = Path(build_root)
435
+ out: list[tuple[Path, str]] = []
436
+ for rel, emitter, conditional in _SHARED_EMITTERS:
437
+ contents = emitter(project)
438
+ if conditional and not contents:
439
+ continue
440
+ out.append((build_root.joinpath(*rel.split("/")), contents))
441
+ return out
442
+
443
+
444
+ def _shared_artefact(project: BoardProject, name: str) -> str:
445
+ """Contents of the shared artefact at `name` (its full path under the build
446
+ root, e.g. `generated/dts-partitions.dtsi`), running ONLY that artefact's
447
+ emitter. The standalone `--emit` modes render through this, so they and
448
+ the plan's `sharedArtefacts[].contents` share one call site per artefact.
449
+
450
+ The unconditional artefacts always exist; an unknown `name` raises. A
451
+ conditional one (sysbuild / TF-M) yields "" when its emit is empty.
452
+ """
453
+ matches = [(emitter, conditional) for rel, emitter, conditional
454
+ in _SHARED_EMITTERS if rel == name]
455
+ if len(matches) != 1:
456
+ raise OrchestratorError(
457
+ f"no unique shared artefact '{name}' ({len(matches)} matches)")
458
+ emitter, _conditional = matches[0]
459
+ return emitter(project)
460
+
461
+
462
+ def _shared_tfm_conf(project: BoardProject) -> str:
463
+ """The `sysbuild/tfm/tfm.conf` contents, or "" when the project has none
464
+ (absence-emits-nothing). Runs only the TF-M emitter (tan-cli#1216)."""
465
+ return _shared_artefact(project, "sysbuild/tfm/tfm.conf")
466
+
467
+
468
+ def _slice_toolchain(slice_: Slice) -> dict[str, Optional[str]]:
469
+ """This slice's compiler identity: `{targetTriple, compiler, sysroot, id}`
470
+ (#610 §4 per-slice tooling index).
471
+
472
+ Grounded in the SoM preset's `topology.<core>.toolchain` default, or a
473
+ project's `cores.<core>.toolchain` override (issue #964) -- the same
474
+ field `Slice.to_manifest_entry` already surfaces in
475
+ `system-manifest.yaml` -- never invented. For a Zephyr slice this
476
+ value (e.g. `arm-zephyr-eabi`) IS the real Zephyr SDK toolchain
477
+ directory name AND its GCC target triple, so `targetTriple` /
478
+ `compiler` derive straight from it. A Yocto slice's toolchain tag
479
+ (`poky-glibc`) is a *category* (C-library flavour), not a literal
480
+ GCC triple -- the real triple depends on the Yocto build's own
481
+ TUNE_FEATURES/TCLIBC (outside board.yaml / SoM metadata), so
482
+ `targetTriple` / `compiler` / `sysroot` stay null rather than
483
+ guess an ABI suffix (e.g. `gnueabi` vs `gnueabihf`). Zephyr has no
484
+ conventional cross-compile sysroot either (the SDK bundles its own
485
+ libc per architecture) -- `sysroot` is null for every runtime today.
486
+ """
487
+ target_triple: Optional[str] = None
488
+ compiler: Optional[str] = None
489
+ if slice_.os == "zephyr" and slice_.toolchain:
490
+ target_triple = slice_.toolchain
491
+ compiler = f"{slice_.toolchain}-gcc"
492
+ return {
493
+ "targetTriple": target_triple,
494
+ "compiler": compiler,
495
+ "sysroot": None,
496
+ "id": slice_.toolchain,
497
+ }
498
+
499
+
500
+ #: An artifact block that claims nothing. Shared by the runtimes with
501
+ #: no honest path to report (yocto) and by any slice whose `command` was
502
+ #: blocked, so the two can never drift apart.
503
+ _NULL_ARTIFACTS: dict[str, Optional[str]] = {
504
+ "elf": None,
505
+ "map": None,
506
+ "bin": None,
507
+ "sizeReport": None,
508
+ "symbols": None,
509
+ "compileCommands": None,
510
+ "outputDir": None,
511
+ }
512
+
513
+
514
+ def _slice_artifacts(build_dir: Path, slice_: Slice,
515
+ has_command: bool = True) -> dict[str, Optional[str]]:
516
+ """Deterministic OUTPUT paths under `build_dir` (#610 §4) -- the
517
+ WHERE, not a promise the files already exist (they don't until the
518
+ slice is actually built).
519
+
520
+ Zephyr's own CMake layout fixes these names: `cmake/modules/
521
+ kernel.cmake` (`PROJECT_BINARY_DIR = CMAKE_CURRENT_BINARY_DIR/
522
+ zephyr`, `KERNEL_ELF_NAME`/`_BIN_NAME`/`_MAP_NAME`/`_SYMBOLS_NAME`/
523
+ `_STAT_NAME`) always lands `zephyr.elf` / `.bin` / `.map` /
524
+ `.symbols` / `.stat` in a `zephyr/` subdirectory of the build dir
525
+ (`.symbols` / `.stat` are gated behind the opt-in `CONFIG_
526
+ OUTPUT_SYMBOLS` / `CONFIG_OUTPUT_STAT`, same "doesn't exist until
527
+ built/enabled" caveat as the rest); the top-level `CMakeLists.txt`
528
+ forces `CMAKE_EXPORT_COMPILE_COMMANDS` unconditionally, always to
529
+ the build dir root, not the `zephyr/` subdirectory.
530
+
531
+ That build dir root is `<buildDir>/build/`, NOT `<buildDir>` --
532
+ `_slice_command` deliberately emits no `-d` (finding M14: west
533
+ resolves a relative `-d` against its own cwd, already `<buildDir>`,
534
+ so `-d <buildDir>` would double-nest), and west's default output is
535
+ `<cwd>/build`. Every zephyr path below therefore carries that
536
+ `build/` level. It used to be missing (issue #1360): the plan named
537
+ `<buildDir>/zephyr/zephyr.elf`, a path west never creates, and every
538
+ consumer had to re-derive the real one from `buildDir` instead of
539
+ reading the block it was given -- tan's since-retired `renode`
540
+ verb (ADR 0022) looked for `build/m55_he-zephyr/zephyr/zephyr.elf`
541
+ and found nothing. The
542
+ `build/` level is west's, not this planner's, which is exactly why
543
+ it belongs in the reported path and not in each reader's head.
544
+
545
+ A Yocto slice's real output (the wic/ext4 image) lands under the
546
+ *Yocto build tree's* own deploy dir -- outside this slice's
547
+ `build_dir`, which only ever carries the `local.conf` fragment --
548
+ so there is no honest path to report.
549
+
550
+ `baremetal` reports the ONE path its configure line GUARANTEES
551
+ (tan-cli#550 -- the whole block used to be null, so a slice that
552
+ produced no binary at all was indistinguishable from one that built
553
+ fine): `outputDir`. `_slice_command` passes
554
+ `-DCMAKE_RUNTIME_OUTPUT_DIRECTORY=$<1:<buildDir>/output>`, so every
555
+ RUNTIME artifact the app links -- i.e. every `add_executable()`
556
+ target -- lands there, on single- and multi-config generators alike
557
+ (the `$<1:...>` generator expression is what suppresses the
558
+ per-config subdirectory a multi-config generator would otherwise
559
+ append).
560
+
561
+ What `outputDir` does NOT license is the inverse reading. An empty
562
+ or absent `output/` after the build step does NOT prove the slice
563
+ produced nothing: `CMAKE_RUNTIME_OUTPUT_DIRECTORY` governs
564
+ `add_executable` targets ONLY, so a firmware app built as
565
+ `add_library(fwcore STATIC ...)` plus a custom link/objcopy target
566
+ builds cleanly (rc 0, `libfwcore.a` + `main.c.o` + `fw.elf`
567
+ produced) and never creates `output/` at all -- measured, not
568
+ assumed. It is a deterministic PLACE TO LOOK, not a
569
+ build-succeeded oracle.
570
+
571
+ `compileCommands` stays null for baremetal even though the
572
+ configure passes `-DCMAKE_EXPORT_COMPILE_COMMANDS=ON`: CMake
573
+ implements that variable "only by Makefile Generators and Ninja
574
+ Generators. It is ignored on other generators" (CMake's own docs
575
+ for the variable), and this planner does not choose the generator.
576
+ On Windows, whose default generator is Visual Studio, the file is
577
+ never written -- so naming the path here would be exactly the
578
+ artifacts-lie tan-cli#550 is about, pointed the other way. A zephyr
579
+ slice's `compileCommands` is a different case and stays: `west
580
+ build` drives Ninja and Zephyr's own top-level `CMakeLists.txt`
581
+ forces the variable on.
582
+
583
+ `elf` / `bin` / `map` / `sizeReport` / `symbols` stay null for
584
+ baremetal: the executable's NAME is the app's own `CMakeLists.txt`
585
+ to pick, not an SDK convention this emitter may invent -- pinning
586
+ the DIRECTORY is the strongest honest claim, which is exactly what
587
+ `outputDir` carries.
588
+
589
+ `has_command` is False for a slice whose `command` was BLOCKED
590
+ (`command-unrooted` / `board-tree-missing` / `no-command`): nothing
591
+ will ever configure that build dir, so every path below would be a
592
+ dangling promise pinned by a configure that never runs. Such a
593
+ slice reports an all-null block.
594
+ """
595
+ if not has_command:
596
+ return dict(_NULL_ARTIFACTS)
597
+ if slice_.os == "zephyr":
598
+ # west's own build tree: `<buildDir>/build/`, because the command
599
+ # runs with cwd=<buildDir> and no `-d` (see the docstring above
600
+ # and `_slice_command`'s M14 note).
601
+ wdir = build_dir / "build"
602
+ zdir = wdir / "zephyr"
603
+ return {
604
+ "elf": (zdir / "zephyr.elf").as_posix(),
605
+ "map": (zdir / "zephyr.map").as_posix(),
606
+ "bin": (zdir / "zephyr.bin").as_posix(),
607
+ "sizeReport": (zdir / "zephyr.stat").as_posix(),
608
+ "symbols": (zdir / "zephyr.symbols").as_posix(),
609
+ "compileCommands": (wdir / "compile_commands.json").as_posix(),
610
+ # Zephyr's own tree lands at `<buildDir>/build/` and the six
611
+ # named paths above all index it -- no separate output
612
+ # directory to report.
613
+ "outputDir": None,
614
+ }
615
+ if slice_.os == "baremetal":
616
+ return dict(_NULL_ARTIFACTS,
617
+ outputDir=(build_dir / "output").as_posix())
618
+ return dict(_NULL_ARTIFACTS)
619
+
620
+
621
+ def _slice_debug(
622
+ project: BoardProject,
623
+ slice_: Slice,
624
+ flash_method: Optional[str],
625
+ flash_args: Optional[dict[str, Any]],
626
+ ) -> dict[str, Optional[str]]:
627
+ """Headless monitor/debug selectors for this slice: `{console, probe}`
628
+ (#610 §4).
629
+
630
+ `console` reuses `_resolve_console` -- the same OS-derived /
631
+ `diagnostics.console:`-overridable selector `_slice_alp_conf` /
632
+ `_slice_local_conf` already emit Kconfig from. `"none"` means
633
+ "inherit whatever the board provides" -- not a concrete selector a
634
+ headless consumer can act on -- so it maps to null here. `probe`
635
+ reuses `_slice_flash_recipe`'s already-resolved runner (the same
636
+ fact `tan flash` dispatches on, computed once per slice by the
637
+ caller and passed in): `_slice_flash_recipe` no longer forces a
638
+ runner for `zephyr_west_flash` slices (not every in-tree board
639
+ registers `openocd`), so `probe` is the explicitly-configured
640
+ runner when one is set, else null -- meaning "board.cmake default,
641
+ not independently knowable here". The Yocto image-flash recipe and
642
+ the baremetal cmake recipe don't identify a live debug probe
643
+ either, so `probe` stays null there too.
644
+ """
645
+ console_sel = _resolve_console(
646
+ project.diagnostics.get("console"), slice_.os, slice_.hw_console)
647
+ console = None if console_sel == "none" else console_sel
648
+ probe: Optional[str] = None
649
+ if flash_method == "zephyr_west_flash":
650
+ probe = (flash_args or {}).get("runner")
651
+ return {"console": console, "probe": probe}
652
+
653
+
654
+ def _sdk_version() -> Optional[str]:
655
+ """The `version:` field out of `metadata/sdk_version.yaml` -- the single
656
+ source `scripts/bump_version.py` bumps and `check_version_doc_sync.py`
657
+ pins every other copy against. Same read-and-strip idiom as
658
+ `alp_cli._version` / `check_version_doc_sync.declared_version`, kept
659
+ inline here rather than imported: neither of those lives on an import
660
+ path this package can reach without a sys.path hack (`scripts/` itself
661
+ carries no `__init__.py`).
662
+ """
663
+ sdk_version_yaml = REPO / "metadata" / "sdk_version.yaml"
664
+ try:
665
+ text = sdk_version_yaml.read_text(encoding="utf-8")
666
+ except OSError:
667
+ # No adjacent metadata/ tree (e.g. packaged as a wheel) -- provenance
668
+ # is best-effort, never a reason to fail the emit. Mirrors the
669
+ # OSError guard in `alp_cli._version` and `_sdk_commit` below.
670
+ return None
671
+ for line in text.splitlines():
672
+ stripped = line.strip()
673
+ if stripped.startswith("version:"):
674
+ return stripped.split(":", 1)[1].split("#", 1)[0].strip()
675
+ return None
676
+
677
+
678
+ def _sdk_commit() -> Optional[str]:
679
+ """Short git commit of this checkout (`git rev-parse --short HEAD`), or
680
+ `None` when git -- or a `.git` dir -- isn't available. Mirrors the
681
+ robustness of `scripts/build_receipt.py::_git_rev` (try/except over both
682
+ `CalledProcessError` and a missing `git` binary) rather than importing
683
+ it: that module isn't reachable from this package without the same
684
+ sys.path workaround noted on `_sdk_version` above, and receipt's variant
685
+ also resolves the FULL rev plus a dirty-tree flag this envelope doesn't
686
+ need.
687
+
688
+ `git` is resolved through `tool_lookup.resolve_tool` and spawned with
689
+ `executable=` pinned, the same treatment `token_substitution.py`'s
690
+ build-side git-provenance pair got (tan-cli#797): `emit_build_plan` runs
691
+ with the process cwd sitting in the CUSTOMER's project directory (`tan
692
+ build` is invoked from there, not from `REPO`), so a bare `"git"` argv[0]
693
+ here is the same Windows `CreateProcess`-searches-cwd-first hazard as
694
+ those two -- a checked-in or unpacked `git.exe` at the project root would
695
+ have run in `git`'s place and silently corrupted the emitted plan's
696
+ `sdkCommit`. `resolve_tool` returning `None` (git not on PATH at all)
697
+ degrades to "no commit", the same no-signal answer a `CalledProcessError`
698
+ already gave -- never a crash of the emit.
699
+ """
700
+ git_exe = resolve_tool("git", os.environ).resolved
701
+ if git_exe is None:
702
+ return None
703
+ try:
704
+ result = subprocess.run(
705
+ ["git", "-C", str(REPO), "rev-parse", "--short", "HEAD"],
706
+ executable=git_exe,
707
+ capture_output=True, text=True, encoding="utf-8",
708
+ env=spawn_env(), check=True)
709
+ except (subprocess.CalledProcessError, OSError):
710
+ return None
711
+ commit = result.stdout.strip()
712
+ return commit or None
713
+
714
+
715
+ def emit_build_plan(
716
+ project: BoardProject,
717
+ *,
718
+ board_yaml: Path,
719
+ build_root: Path,
720
+ ) -> str:
721
+ """Emit the machine-readable build plan as JSON (Wave C contract).
722
+
723
+ Consumed by `tan` (alplabai/tan-cli), which materialises the plan's
724
+ files, runs each slice's command, and owns scheduling / caching /
725
+ progress UX on top -- instead of re-implementing this planner. The
726
+ Wave C contract was settled 2026-06-04 with the alp-sdk-vscode team
727
+ (docs/PROPOSAL-alp-build-core.md records that settlement); the real
728
+ parser today is tan-cli, not the alp-sdk-vscode extension itself.
729
+
730
+ Contract notes (locked with the consumer -- bump `schemaVersion`
731
+ and flag in the CHANGELOG before changing the shape):
732
+
733
+ * camelCase keys; `schemaVersion` is independent of board.yaml's
734
+ schema version.
735
+ * Every artefact carries its `contents` so the consumer's
736
+ materialise step stays pure IO. `_shared_artefacts` /
737
+ `_slice_config_artefact` are the single sources both this emit
738
+ and the Orchestrator's own materialise step read, so the two
739
+ cannot drift.
740
+ * No `inputHash` (the consumer computes its own cache key over
741
+ the plan) and no `sequential` (parallelism policy belongs to
742
+ the consumer's scheduler).
743
+ * One slice per non-`off` core, sorted by coreId. A slice this
744
+ script cannot build yet (e.g. no `app:`) is carried with
745
+ `command: null` plus a `no-command` warning -- never dropped,
746
+ so the consumer can still report the core. Same treatment for
747
+ a zephyr slice whose `board:` target has no tree under
748
+ `zephyr/boards/alp/` (`board-tree-missing`, issue #999): the
749
+ plan never carries a `west build -b <board>` command that is
750
+ guaranteed to fail Zephyr's own board lookup.
751
+ * Write-free: nothing is created on disk. (Command resolution
752
+ stats the app dir to pick the CMakeLists.txt convention --
753
+ read-only, same as the build itself.)
754
+ * Per-slice tooling index (#610 §4, additive to schemaVersion 1 --
755
+ never renamed/removed, see `metadata/schemas/build-plan-v1.
756
+ schema.json`): `toolchain` (compiler identity --
757
+ `_slice_toolchain`, keys `targetTriple`/`compiler`/`sysroot`/`id`),
758
+ `artifacts` (deterministic OUTPUT paths under `buildDir`, not a
759
+ promise they exist yet -- `_slice_artifacts`, keys `elf`/`map`/
760
+ `bin`/`sizeReport`/`symbols`/`compileCommands`), and `debug`
761
+ (headless console/probe selectors -- `_slice_debug`). A field
762
+ genuinely not derivable for a runtime (e.g. a Yocto slice's exact
763
+ GCC triple) is null, never guessed. (These three sub-objects'
764
+ keys were corrected from an accidental snake_case to camelCase
765
+ to match the rest of this contract -- see the CHANGELOG.)
766
+ * Envelope provenance, additive to schemaVersion 1: `sdkVersion`
767
+ (`metadata/sdk_version.yaml`'s `version:`, via `_sdk_version`)
768
+ and `sdkCommit` (`git rev-parse --short HEAD` of this checkout,
769
+ via `_sdk_commit`; `null` when git/`.git` isn't available -- never
770
+ raises) so a cached/materialised plan can be traced back to the
771
+ planner that produced it.
772
+ """
773
+ # Orchestrator-side (stay inline until orchestrator.py); lazy to avoid
774
+ # a buildplan<->package import cycle.
775
+ from .cameras import CameraSelectError
776
+ from .orchestrator import (
777
+ STOCK_IMAGE_APP,
778
+ UnbuildableYoctoMachineError,
779
+ UnknownBoardTargetError,
780
+ UnrootedPathError,
781
+ _resolve_app_path,
782
+ _slice_command,
783
+ _slice_flash_recipe,
784
+ _slice_post_commands,
785
+ _tokenize,
786
+ iter_buildable_slices,
787
+ )
788
+ build_root = Path(build_root)
789
+ # Anchor every slice's relative `app:` on the board.yaml's own
790
+ # directory, never the emitting process's CWD -- the plan must be
791
+ # byte-identical no matter where `--emit build-plan` is invoked from
792
+ # (issue #596).
793
+ base_dir = Path(board_yaml).resolve().parent
794
+ slices_out: list[dict[str, Any]] = []
795
+ warnings: list[dict[str, Any]] = []
796
+
797
+ for slice_ in iter_buildable_slices(project):
798
+ build_dir = _slice_build_dir(build_root, slice_)
799
+ # `replace` keeps this emit side-effect free: _slice_command
800
+ # reads `build_dir` off the slice (baremetal -B), and the
801
+ # project's own Slice objects must stay untouched.
802
+ try:
803
+ cmd = _slice_command(
804
+ project, replace(slice_, build_dir=build_dir),
805
+ base_dir=base_dir)
806
+ except UnrootedPathError as e:
807
+ # One of _slice_command's five `_tokenize` call sites (west
808
+ # build's app-dir arg, -DSB_CONF_FILE's two paths,
809
+ # -DEXTRA_CONF_FILE, cmake -S) hit a path outside both
810
+ # ${PROJECT_ROOT} and ${SDK_ROOT} (issue #865). Block the
811
+ # command rather than ever let a bare absolute path reach
812
+ # `command.args` on a plan tagged `planPathMode: tokened` --
813
+ # same "carry the slice, never emit a broken/non-hermetic
814
+ # command" convention as `no-command` below.
815
+ cmd = None
816
+ warnings.append({
817
+ "code": "command-unrooted",
818
+ "coreId": slice_.core_id,
819
+ "message": (f"core '{slice_.core_id}' build command would "
820
+ f"embed a path outside both the project "
821
+ f"({base_dir}) and the SDK checkout ({REPO}): "
822
+ f"'{e}' -- blocked rather than emit a "
823
+ f"non-hermetic command"),
824
+ })
825
+ except UnknownBoardTargetError as e:
826
+ # The SoM preset's topology named a Zephyr board with no
827
+ # tree under zephyr/boards/alp/ (issue #999 one layer down
828
+ # from the declaration gate, at emit time): block the
829
+ # command rather than ever hand a consumer (tan, or a
830
+ # customer's own `west build`) a `-b <board>` argument that
831
+ # is guaranteed to die with Zephyr's own "No board named
832
+ # ... Invalid BOARD" error -- same "carry the slice, never
833
+ # emit a broken command" convention as `no-command` below.
834
+ cmd = None
835
+ warnings.append({
836
+ "code": "board-tree-missing",
837
+ "coreId": slice_.core_id,
838
+ "message": str(e),
839
+ })
840
+ except CameraSelectError as e:
841
+ # A `cameras:` entry that cannot become a -DSHIELD for this
842
+ # slice (module without a zephyr_shield, or a carrier shield
843
+ # with no overlay for the board target): block, never emit a
844
+ # command that silently drops the camera.
845
+ cmd = None
846
+ warnings.append({
847
+ "code": "camera-select-failed",
848
+ "coreId": slice_.core_id,
849
+ "message": str(e),
850
+ })
851
+ except UnbuildableYoctoMachineError as e:
852
+ # The slice's `machine:` is a known-non-buildable Yocto MACHINE
853
+ # (issue #1982 -- the two AEN A32-cluster carriers, unbuildable
854
+ # for related but distinct reasons: see
855
+ # `orchestrator.YOCTO_MACHINE_UNBUILDABLE`'s own comment,
856
+ # issues #1968 / #1971): block the command rather than ever
857
+ # hand a consumer a `bitbake` target that cannot succeed --
858
+ # same "carry the slice, never emit a broken command"
859
+ # convention as `no-command` below.
860
+ cmd = None
861
+ warnings.append({
862
+ "code": "yocto-machine-unbuildable",
863
+ "coreId": slice_.core_id,
864
+ "message": str(e),
865
+ })
866
+ else:
867
+ if cmd is None:
868
+ if (slice_.os == "yocto" and slice_.app
869
+ and not slice_.image and not slice_.recipe):
870
+ # An app-only Yocto slice with no `recipe:` has no valid
871
+ # bitbake target -- `app:` is a source directory, not a
872
+ # recipe name (issue #597). Block the plan explicitly
873
+ # instead of ever emitting `bitbake <path>`.
874
+ warnings.append({
875
+ "code": "yocto-recipe-missing",
876
+ "coreId": slice_.core_id,
877
+ "message": (f"core '{slice_.core_id}' has app: "
878
+ f"'{slice_.app}' but no recipe: -- add "
879
+ f"the bitbake recipe name that packages "
880
+ f"this app source, or set image: to "
881
+ f"build a stock image instead"),
882
+ })
883
+ else:
884
+ warnings.append({
885
+ "code": "no-command",
886
+ "coreId": slice_.core_id,
887
+ "message": (f"no build command for core "
888
+ f"'{slice_.core_id}' (os: {slice_.os}) "
889
+ f"-- missing app/board/image"),
890
+ })
891
+ config_artefacts: list[dict[str, str]] = []
892
+ # A baremetal slice's `alp-baremetal.cmake` has exactly ONE reader:
893
+ # the `-DCMAKE_PROJECT_INCLUDE=` arg on this slice's own configure.
894
+ # If the command was blocked there is no reader, so materialising
895
+ # the file would leave a consumer holding a path nothing will ever
896
+ # open. (A zephyr/yocto artefact is a plain config fragment a human
897
+ # can still read, so those are emitted either way.)
898
+ artefact = (None if (cmd is None and slice_.os == "baremetal")
899
+ else _slice_config_artefact(project, slice_))
900
+ if artefact is not None:
901
+ name, contents = artefact
902
+ config_artefacts.append({
903
+ "path": (build_dir / name).as_posix(),
904
+ "contents": contents,
905
+ })
906
+ # `diagnostics.link: itcm` (tan-cli#1350): the ITCM retarget
907
+ # conf + overlay ride beside `alp.conf`; `_slice_command` wires
908
+ # them in. Only when the command exists to read them.
909
+ if cmd is not None:
910
+ for extra_name, extra_contents in extra_config_artefacts(
911
+ project, slice_):
912
+ config_artefacts.append({
913
+ "path": (build_dir / extra_name).as_posix(),
914
+ "contents": extra_contents,
915
+ })
916
+ # Additive rendered-text artefacts (ADR-0026 §D, tan-cli#1216): the
917
+ # DTS overlay, the full `-D` listing, the build-identifier header and
918
+ # the west fragment, byte-identical to the standalone
919
+ # `--emit dts-overlay|cmake-args|hw-info-h|west-libraries --core`
920
+ # renders. Always AFTER the slice's primary config artefact, so a
921
+ # consumer that reads `configArtefacts[0]` is unaffected.
922
+ extras: list[Optional[tuple[str, str]]] = []
923
+ # Same dangling-path rule as `alp-baremetal.cmake` above: a baremetal
924
+ # slice whose command was blocked will never configure its build dir,
925
+ # so promise it nothing.
926
+ if not (cmd is None and slice_.os == "baremetal"):
927
+ try:
928
+ extras.append(_slice_dts_overlay_artefact(project, slice_))
929
+ except DtsOverlayUnavailable as exc:
930
+ # Additive artefact: a board with no header (or an
931
+ # unrecognised SKU) must not stop a plan that emitted fine
932
+ # before. The consumer sees the warning and falls back.
933
+ # Any other OrchestratorError still fails the plan.
934
+ warnings.append({
935
+ "code": "dts-overlay-unavailable",
936
+ "coreId": slice_.core_id,
937
+ "message": (f"core '{slice_.core_id}': no `alp.overlay` "
938
+ f"artefact -- {exc}"),
939
+ })
940
+ try:
941
+ extras.append(_slice_cmake_args_artefact(project, slice_))
942
+ except CameraSelectError as exc:
943
+ # The listing is not emitted; make sure the plan still says
944
+ # why (the command path usually already did).
945
+ if not any(w["code"] == "camera-select-failed"
946
+ and w["coreId"] == slice_.core_id for w in warnings):
947
+ warnings.append({
948
+ "code": "camera-select-failed",
949
+ "coreId": slice_.core_id,
950
+ "message": str(exc),
951
+ })
952
+ try:
953
+ extras.append(_slice_hw_info_artefact(project, slice_))
954
+ except HwInfoUnavailable as exc:
955
+ warnings.append({
956
+ "code": "hw-info-unavailable",
957
+ "coreId": slice_.core_id,
958
+ "message": (f"core '{slice_.core_id}': no "
959
+ f"`alp_hw_info_build.h` artefact -- {exc}"),
960
+ })
961
+ extras.append(_slice_west_libs_artefact(project, slice_))
962
+ for extra in extras:
963
+ if extra is not None:
964
+ config_artefacts.append({
965
+ "path": (build_dir / extra[0]).as_posix(),
966
+ "contents": extra[1],
967
+ })
968
+ # `appDir` retains the resolved source directory independent of
969
+ # `command` -- tooling that wants the app source (e.g. to watch
970
+ # it for incremental rebuilds) doesn't have to reverse-engineer
971
+ # it out of a yocto/zephyr/baremetal-shaped command (issue #597).
972
+ # `alp-image-edge` is the A-core stock-image token, not a source
973
+ # path -- there is no app dir to report for it.
974
+ # Tokenized (issue #865): almost every `app:` resolves under the
975
+ # project (`${PROJECT_ROOT}/...`); the SDK-owned stock M-core shim
976
+ # (STOCK_SHIM_APP) resolves under the SDK checkout instead
977
+ # (`${SDK_ROOT}/...`). `_tokenize` raises `UnrootedPathError` when
978
+ # it is under neither -- fall back to the absolute path (still
979
+ # useful for tooling) but flag it rather than let it silently
980
+ # mis-root on the consumer side.
981
+ app_dir = None
982
+ if slice_.app and slice_.app != STOCK_IMAGE_APP:
983
+ resolved_app = _resolve_app_path(slice_.app, base_dir)
984
+ try:
985
+ app_dir = _tokenize(resolved_app, base_dir, REPO)
986
+ except UnrootedPathError:
987
+ app_dir = resolved_app.as_posix()
988
+ warnings.append({
989
+ "code": "appdir-unrooted",
990
+ "coreId": slice_.core_id,
991
+ "message": (f"core '{slice_.core_id}' app path "
992
+ f"'{app_dir}' is outside both the project "
993
+ f"({base_dir}) and the SDK checkout "
994
+ f"({REPO}) -- emitted absolute; a consumer "
995
+ f"on a different checkout cannot re-root "
996
+ f"it"),
997
+ })
998
+ # Same flash-recipe fact `Slice.to_manifest_entry` surfaces to
999
+ # `tan flash` -- reused here (not re-derived) so `debug.probe`
1000
+ # can never drift from the manifest's own `flash_method`/`flash_args`.
1001
+ flash_method, flash_args = _slice_flash_recipe(slice_)
1002
+ slices_out.append({
1003
+ "coreId": slice_.core_id,
1004
+ "backend": slice_.os,
1005
+ "buildDir": build_dir.as_posix(),
1006
+ "appDir": app_dir,
1007
+ "configArtefacts": config_artefacts,
1008
+ "toolchain": _slice_toolchain(slice_),
1009
+ "artifacts": _slice_artifacts(build_dir, slice_,
1010
+ has_command=cmd is not None),
1011
+ "debug": _slice_debug(
1012
+ project, slice_, flash_method, flash_args),
1013
+ "command": None if cmd is None else {
1014
+ "tool": cmd[0],
1015
+ "args": cmd[1:],
1016
+ "cwd": build_dir.as_posix(),
1017
+ },
1018
+ # Steps the executor MUST run, in order, after `command`
1019
+ # succeeds -- empty for the runtimes whose single tool
1020
+ # invocation already configures AND builds (`west build`,
1021
+ # `bitbake`). A baremetal slice's `cmake -S ... -B .` only
1022
+ # CONFIGURES, so its `cmake --build .` lives here; without it
1023
+ # the slice exited 0 having produced no object file, archive
1024
+ # or executable at all (tan-cli#550). Empty whenever
1025
+ # `command` is null: there is nothing to build on top of a
1026
+ # slice that was never configured.
1027
+ "postCommands": [] if cmd is None else [
1028
+ {
1029
+ "tool": step[0],
1030
+ "args": step[1:],
1031
+ "cwd": build_dir.as_posix(),
1032
+ }
1033
+ for step in _slice_post_commands(slice_)
1034
+ ],
1035
+ # Tokened (issue #865), not the native host-path form: tan-cli
1036
+ # (PR #24) substitutes ${SDK_ROOT} with its own checkout root
1037
+ # before handing this to the slice subprocess environment, so
1038
+ # a cached/materialised plan never carries THIS run's absolute
1039
+ # path onto a different machine/checkout.
1040
+ "env": {"ALP_SDK_ROOT": "${SDK_ROOT}"},
1041
+ # SDK-owned values the consumer APPENDS to its own env, distinct
1042
+ # from `env` above (set-verbatim). The join separator is
1043
+ # PER-KEY, not uniformly os.pathsep: EXTRA_ZEPHYR_MODULES is a
1044
+ # CMake list Zephyr's zephyr_module.py splits on `;` on every
1045
+ # platform (not an OS path list), while PYTHONPATH is a real
1046
+ # OS-native path list (os.pathsep). Mirrors the append
1047
+ # `_alp_common.env_with_sdk` / `_workspace.subprocess_env` do
1048
+ # for a real `west build` (ADR-0020 item 3). Tokened same as
1049
+ # `env` above (issue #865).
1050
+ "envAppendPath": {
1051
+ "EXTRA_ZEPHYR_MODULES": ["${SDK_ROOT}"],
1052
+ "PYTHONPATH": ["${SDK_ROOT}/scripts"],
1053
+ },
1054
+ })
1055
+
1056
+ shared_out = [
1057
+ {"path": p.as_posix(), "contents": c}
1058
+ for p, c in _shared_artefacts(project, build_root)
1059
+ ]
1060
+ plan: dict[str, Any] = {
1061
+ "schemaVersion": 1,
1062
+ # Additive to schemaVersion 1 (issue #865): every path in this plan
1063
+ # that anchors on THIS checkout or THIS project is now emitted as
1064
+ # a `${SDK_ROOT}`/`${PROJECT_ROOT}`/`${PYTHON}` token rather than a
1065
+ # baked-in absolute path -- tan-cli (PR #24) requires this literal
1066
+ # value before it will substitute them. `boardYaml` is deliberately
1067
+ # NOT tokenized (kept repo-relative as-passed) -- it is the anchor
1068
+ # both this plan's own comparator and tan use to locate
1069
+ # PROJECT_ROOT in the first place.
1070
+ "planPathMode": "tokened",
1071
+ # Additive (issue #2696): the `${NAME}` values left unresolved on
1072
+ # purpose, so a consumer never mistakes one for a plan path token
1073
+ # above. Always present, `[]` when there are none, so a consumer can
1074
+ # tell "none" from a pre-#2696 plan that does not carry the field.
1075
+ "deferredPlaceholders": _deferred_placeholders(
1076
+ slices_out, shared_out, board_yaml),
1077
+ "generatedBy": "scripts/alp_orchestrate.py",
1078
+ # Additive provenance (ADR 0014's additive rule -- no schemaVersion
1079
+ # bump): traces a cached/materialised plan back to the planner that
1080
+ # produced it. `sdkCommit` is null, never a crash, when git/`.git`
1081
+ # isn't available (e.g. a wheel-installed CLI with no checkout).
1082
+ "sdkVersion": _sdk_version(),
1083
+ "sdkCommit": _sdk_commit(),
1084
+ "boardYaml": Path(board_yaml).as_posix(),
1085
+ "sku": project.sku,
1086
+ "buildRoot": build_root.as_posix(),
1087
+ "executionPolicy": _EXECUTION_POLICY,
1088
+ "slices": slices_out,
1089
+ "sharedArtefacts": shared_out,
1090
+ "warnings": warnings,
1091
+ }
1092
+ return json.dumps(plan, indent=2) + "\n"