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,600 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Pure build-plan token substitution (alp-sdk #865, "hermetic build plans").
3
+
4
+ A **tokened** plan (`planPathMode: "tokened"`) carries literal placeholders --
5
+ `${SDK_ROOT}` / `${PROJECT_ROOT}` / `${PYTHON}` / `${TOOLCHAIN_ROOT}` -- in its
6
+ path-bearing string fields instead of baking in the emitting machine's
7
+ absolute paths. This module is the CONSUMER side: ONE blind string-
8
+ substitution pass swapping the tokens for tan's already-resolved values, plus
9
+ the guards that keep a wrong substitution from silently building the wrong
10
+ image.
11
+
12
+ Pure -- no IO. The caller (`tan.commands.build.token_substitution`) resolves
13
+ the SDK checkout root, the project root (the `board.yaml` directory), the
14
+ planner-venv Python and the toolchain root exactly ONCE and hands them in as
15
+ `TokenValues`; it also owns invoking `git` for the `sdkCommit` check
16
+ (`sdk_commit_mismatches` here only compares strings).
17
+
18
+ A plan without `planPathMode: "tokened"` is untouched by
19
+ `substitute_plan_tokens`: a byte-identical no-op ONLY when the key is ABSENT
20
+ (`plan_path_mode is None`, `:440`) -- present-but-other raises
21
+ `UnknownPlanPathMode` (`:445-446`) instead. Reachable via `--plan-from`
22
+ handing in an older plan with no `planPathMode` key at all -- tan's own
23
+ in-process planner (`tan.planner.buildplan.emit_build_plan`) tags every
24
+ plan it renders `planPathMode: "tokened"` unconditionally (tan-cli#853), so
25
+ an ABSENT `planPathMode` is no longer "every plan the SDK emits today".
26
+
27
+ tan-cli #89: an unresolved `${TOOLCHAIN_ROOT}` splits into two outcomes
28
+ depending on WHERE it survives substitution. `boardYaml` and
29
+ `sharedArtefacts[]` have no owning slice -- no dispatch seam to route a
30
+ skip/fail decision to -- so they keep the hard `UnresolvedToolchainRoot` this
31
+ pass always raised. A slice field, though, has an owning slice AND an owning
32
+ dispatch seam (`executionPolicy.missingTool`, the same knob a missing
33
+ `bitbake` already uses): this is a HOST-provisioning fact, not a plan/version
34
+ bug, so this pass reports it as a `DemotedSlice` instead of erroring the
35
+ whole plan, leaving the skip-vs-fail call to the caller at dispatch time.
36
+ `LeftoverToken` is unaffected either way -- an unknown token is a
37
+ version/bug fact, never demoted, always plan-fatal.
38
+
39
+ Substituting an unresolved token with an empty string would sail past the
40
+ leftover-token guard and build against the wrong tree -- so every unresolved
41
+ token is REFUSED (or demoted, per the rule above), never degraded to "".
42
+
43
+ Ported field-for-field from `crates/tan-core/src/plan_tokens.rs`
44
+ (`substitute_plan_tokens` / `substitute_slice` / `substitute_command_lenient`
45
+ / `substitute_artefact` / `substitute_artefact_lenient`), with one field the
46
+ Rust `BuildSlice` does not carry: `slices[].appDir`, which the Python
47
+ `Slice` (`tan.core.build_plan`) does have. It is substituted immediately
48
+ after `buildDir` -- the other slice-level bare path field -- and, like every
49
+ other slice field, participates in TOOLCHAIN_ROOT demotion / LeftoverToken
50
+ the same as its siblings. `appDir` is nullable per the SDK schema (a Yocto
51
+ slice built from the stock-image token has none) -- `None` passes through
52
+ untouched, the same as `command.cwd`.
53
+ """
54
+ import re
55
+ import sys
56
+ from dataclasses import dataclass, replace
57
+ from typing import Any
58
+
59
+ from tan.core.build_plan import (
60
+ DEFERRED_PLACEHOLDER_NAME,
61
+ BuildPlan,
62
+ Slice,
63
+ SliceCommand,
64
+ )
65
+
66
+ PLAN_PATH_MODE_TOKENED = "tokened"
67
+
68
+ TOKEN_SDK_ROOT = "${SDK_ROOT}"
69
+ TOKEN_PROJECT_ROOT = "${PROJECT_ROOT}"
70
+ TOKEN_PYTHON = "${PYTHON}"
71
+ TOKEN_TOOLCHAIN_ROOT = "${TOOLCHAIN_ROOT}"
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class TokenValues:
76
+ """tan's already-resolved substitution values. Resolve each exactly ONCE
77
+ for the whole plan -- never re-resolved per-slice.
78
+
79
+ `toolchain_root` is `None` (or blank) when this host has no toolchain
80
+ root resolved at all. Unresolved is NOT an error by itself: every plan
81
+ the SDK emits today names no `${TOOLCHAIN_ROOT}`, and those must keep
82
+ building on a host with no detectable toolchain install -- resolution is
83
+ lazy, the absence only becomes `UnresolvedToolchainRoot` when a plan
84
+ actually uses the token. Blank is folded into "unresolved" on purpose:
85
+ substituting `""` would turn `${TOOLCHAIN_ROOT}/bin/cmake` into the bare
86
+ `/bin/cmake` and sail past the leftover-token guard with nothing left to
87
+ catch -- the same hole `sdk_root` refuses.
88
+ """
89
+
90
+ sdk_root: str
91
+ project_root: str
92
+ python: str
93
+ toolchain_root: str | None
94
+
95
+
96
+ #: The artefact kinds a placeholder may stay in, told apart by the artefact's
97
+ #: file name (the only thing a plan carries about it). `alp.conf` and every
98
+ #: other `*.conf` except `local.conf` is a Zephyr Kconfig fragment; `local.conf`
99
+ #: is a Yocto bitbake fragment. A `.cmake` file (`alp-baremetal.cmake`, read by
100
+ #: `-DCMAKE_PROJECT_INCLUDE`) or anything unrecognised is never exempt: CMake
101
+ #: WOULD expand `${NAME}`, to empty or to a host value.
102
+ KIND_KCONFIG = "kconfig"
103
+ KIND_BITBAKE = "bitbake"
104
+
105
+ PLAN_TOKEN_NAMES = frozenset(
106
+ t[2:-1] for t in (TOKEN_SDK_ROOT, TOKEN_PROJECT_ROOT, TOKEN_PYTHON, TOKEN_TOOLCHAIN_ROOT)
107
+ )
108
+ _BRACE_NAME = re.compile(r"\$\{(" + DEFERRED_PLACEHOLDER_NAME.pattern + r")\}")
109
+
110
+
111
+ def artefact_kind(path: str) -> str | None:
112
+ """`KIND_KCONFIG` / `KIND_BITBAKE`, or `None` for a file a placeholder
113
+ must never stay in."""
114
+ name = path.replace("\\", "/").rsplit("/", 1)[-1]
115
+ if name == "local.conf":
116
+ return KIND_BITBAKE
117
+ if name.endswith(".conf"):
118
+ return KIND_KCONFIG
119
+ return None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class DeferredPlaceholder:
124
+ """A `${NAME}` left standing in a `configArtefacts[*].contents` value on
125
+ purpose (tan-cli#1302). `live_kconfig` is True when it sits on a
126
+ non-comment line of a Kconfig fragment: Kconfig does not expand `${NAME}`,
127
+ so the firmware would carry the literal text."""
128
+
129
+ name: str
130
+ field: str
131
+ live_kconfig: bool = False
132
+
133
+
134
+ def _line_is_comment(text: str, start: int) -> bool:
135
+ line_start = text.rfind("\n", 0, start) + 1
136
+ return text[line_start:start].lstrip().startswith("#")
137
+
138
+
139
+ class DeferredPolicy:
140
+ """Which leftover `${NAME}` values a config artefact may keep (tan-cli#1302).
141
+
142
+ `listed` is the plan's own `deferredPlaceholders` (alp-sdk#2696), the
143
+ ONLY source: a name is admitted when the plan lists it, and nothing is
144
+ admitted when the plan carries no such key (`None`) -- every plan tan's
145
+ own planner renders carries one, so an absent key means an older or
146
+ hand-written plan, and that keeps the unresolved-token refusal.
147
+
148
+ `exempted` collects every placeholder admitted, in encounter order -- the
149
+ caller reports them.
150
+ """
151
+
152
+ def __init__(self, listed: tuple[str, ...] | None) -> None:
153
+ self._listed = frozenset(listed) if listed is not None else None
154
+ self.exempted: list[DeferredPlaceholder] = []
155
+
156
+ @property
157
+ def has_plan_list(self) -> bool:
158
+ return self._listed is not None
159
+
160
+ @staticmethod
161
+ def hintable(token: str, artefact_path: str) -> bool:
162
+ """Whether a refusal of `token` in this artefact is one a plan
163
+ `deferredPlaceholders` entry would have cured."""
164
+ m = _BRACE_NAME.fullmatch(token)
165
+ return (
166
+ m is not None
167
+ and m.group(1) not in PLAN_TOKEN_NAMES
168
+ and artefact_kind(artefact_path) is not None
169
+ )
170
+
171
+ def admit(self, field: str, token: str, artefact_path: str, text: str, start: int) -> bool:
172
+ """Record and return True when `token` (a full `${...}` string found
173
+ at `start` in `text`, the contents of the artefact at `artefact_path`)
174
+ may stay."""
175
+ if not self.hintable(token, artefact_path):
176
+ return False
177
+ name = _BRACE_NAME.fullmatch(token).group(1) # type: ignore[union-attr]
178
+ if self._listed is None or name not in self._listed:
179
+ return False
180
+ live = artefact_kind(artefact_path) == KIND_KCONFIG and not _line_is_comment(text, start)
181
+ self.exempted.append(DeferredPlaceholder(name=name, field=field, live_kconfig=live))
182
+ return True
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class DemotedSlice:
187
+ """A slice whose fields still name `${TOOLCHAIN_ROOT}` with no host
188
+ value. Reported, not erred: the CALLER routes it to
189
+ `executionPolicy.missingTool` at dispatch -- this pass has no business
190
+ deciding skip vs fail, only noticing the condition and naming where it
191
+ hit."""
192
+
193
+ slice_index: int
194
+ core_id: str
195
+ field: str
196
+
197
+
198
+ class PlanTokenError(Exception):
199
+ """Why `substitute_plan_tokens` refused to hand back a plan."""
200
+
201
+
202
+ class LeftoverToken(PlanTokenError):
203
+ """A `${...}`-shaped token survived substitution -- an unknown token (a
204
+ 5th token this CLI doesn't resolve), an unterminated `${` (truncation/
205
+ typo), or a plan bug."""
206
+
207
+ def __init__(self, field: str, token: str, deferrable: bool = False) -> None:
208
+ super().__init__(
209
+ f"plan field `{field}` still contains an unresolved token `{token}` after substitution"
210
+ )
211
+ self.field = field
212
+ self.token = token
213
+ #: A config-artefact `${NAME}` that a plan `deferredPlaceholders`
214
+ #: entry would have admitted (tan-cli#1302).
215
+ self.deferrable = deferrable
216
+
217
+
218
+ class UnresolvedToolchainRoot(PlanTokenError):
219
+ """The plan names `${TOOLCHAIN_ROOT}` but this host has no toolchain root
220
+ resolved, in a field with no owning slice to demote to."""
221
+
222
+ def __init__(self, field: str) -> None:
223
+ super().__init__(
224
+ f"plan field `{field}` names `{TOKEN_TOOLCHAIN_ROOT}` but no toolchain root is "
225
+ f"resolved on this host"
226
+ )
227
+ self.field = field
228
+
229
+
230
+ class UnknownPlanPathMode(PlanTokenError):
231
+ """`planPathMode` is present but isn't the one value this pass knows
232
+ (`"tokened"`)."""
233
+
234
+ def __init__(self, mode: str) -> None:
235
+ super().__init__(f'unknown planPathMode `{mode}` (only "tokened" is defined)')
236
+ self.mode = mode
237
+
238
+
239
+ def sdk_commit_mismatches(plan_commit: str, resolved_commit: str) -> bool:
240
+ """The split-brain guard: whether a plan's `sdkCommit` (when present)
241
+ mismatches the resolved SDK checkout's actual HEAD. Compares by common-
242
+ length prefix so a short (`git rev-parse --short HEAD`) and a full
243
+ 40-char SHA both compare correctly; case-insensitive. Either side blank
244
+ -- an older plan without `sdkCommit`, or a caller that could not resolve
245
+ `git rev-parse` (no `.git`, `git` missing) -- is "no signal", never a
246
+ mismatch: an SDK checkout with no `.git` (a release tarball) is a
247
+ normal, supported setup."""
248
+ a, b = plan_commit.strip(), resolved_commit.strip()
249
+ if not a or not b:
250
+ return False
251
+ n = min(len(a), len(b))
252
+ return a[:n].lower() != b[:n].lower()
253
+
254
+
255
+ def _normalize(path: str) -> str:
256
+ """Lexically normalize (collapse `.`/`..`, drop empty segments) without
257
+ touching the filesystem -- the Python analogue of tan-core's
258
+ `path_guard::normalize`."""
259
+ posix = path.replace("\\", "/")
260
+ is_absolute = posix.startswith("/")
261
+ parts = posix.split("/")
262
+ out: list[str] = []
263
+ for part in parts:
264
+ if part in ("", "."):
265
+ continue
266
+ if part == "..":
267
+ # Matches Rust's `out.pop()`: a no-op on an empty accumulator,
268
+ # never appends a literal "..".
269
+ if out:
270
+ out.pop()
271
+ continue
272
+ out.append(part)
273
+ result = "/".join(out)
274
+ return f"/{result}" if is_absolute else result
275
+
276
+
277
+ def project_root_diverges_from_exec_base(project_root: str, exec_base: str) -> bool:
278
+ """Guard 3: whether `${PROJECT_ROOT}` (the resolved `board.yaml`'s
279
+ directory) diverges from the executor's actual base dir. They're the
280
+ same directory only in the default config -- when a plan is tokened,
281
+ substituting `${PROJECT_ROOT}` from one and executing slices under the
282
+ other would silently build against the wrong tree."""
283
+ a, b = _normalize(project_root), _normalize(exec_base)
284
+ if sys.platform.startswith("win"):
285
+ # Lexical normalize doesn't fold drive-letter case, so
286
+ # `--board-yaml e:/...` vs `--project E:/...` -- the same path --
287
+ # would otherwise false-fail this guard.
288
+ return a.lower() != b.lower()
289
+ return a != b
290
+
291
+
292
+ def _resolved_toolchain_root(values: TokenValues) -> str | None:
293
+ return values.toolchain_root if values.toolchain_root else None
294
+
295
+
296
+ def _apply(values: TokenValues, raw: str) -> str:
297
+ out = raw.replace(TOKEN_SDK_ROOT, values.sdk_root)
298
+ out = out.replace(TOKEN_PROJECT_ROOT, values.project_root)
299
+ out = out.replace(TOKEN_PYTHON, values.python)
300
+ root = _resolved_toolchain_root(values)
301
+ if root is not None:
302
+ out = out.replace(TOKEN_TOOLCHAIN_ROOT, root)
303
+ return out
304
+
305
+
306
+ def _find_brace_token_from(value: str, offset: int) -> tuple[int, str] | None:
307
+ """First `${...}`-shaped substring in `value` at or after `offset`,
308
+ together with its start offset. An unterminated `${` (no closing `}`)
309
+ still counts and consumes the rest of the string -- nothing after an
310
+ unterminated brace could itself be a well-formed further token."""
311
+ start = value.find("${", offset)
312
+ if start == -1:
313
+ return None
314
+ rest = value[start:]
315
+ end = rest.find("}")
316
+ if end == -1:
317
+ return start, rest
318
+ return start, rest[: end + 1]
319
+
320
+
321
+ def _sub_field_lenient(
322
+ field: str,
323
+ raw: str,
324
+ values: TokenValues,
325
+ deferred: DeferredPolicy | None = None,
326
+ artefact_path: str = "",
327
+ ) -> tuple[str, bool]:
328
+ """Substitute `values` into `raw`, then scan the WHOLE result for every
329
+ remaining `${...}`-shaped token -- not just the first: a field can carry
330
+ BOTH an unresolved `${TOOLCHAIN_ROOT}` and a genuinely unknown token, and
331
+ the unknown one must still fail loudly even when it comes second -- an
332
+ unknown token is a version/bug fact and outranks a provisioning fact.
333
+ Returns the substituted string plus whether an unresolved
334
+ `${TOOLCHAIN_ROOT}` was seen; the caller decides whether that's
335
+ plan-fatal (`_sub_field`) or demotable (`_substitute_slice` and its
336
+ helpers)."""
337
+ substituted = _apply(values, raw)
338
+ unresolved_toolchain = False
339
+ offset = 0
340
+ while True:
341
+ found = _find_brace_token_from(substituted, offset)
342
+ if found is None:
343
+ break
344
+ start, token = found
345
+ if token != TOKEN_TOOLCHAIN_ROOT:
346
+ if deferred is not None:
347
+ if deferred.admit(field, token, artefact_path, substituted, start):
348
+ offset = start + len(token)
349
+ continue
350
+ raise LeftoverToken(
351
+ field, token, deferrable=deferred.hintable(token, artefact_path)
352
+ )
353
+ raise LeftoverToken(field, token)
354
+ # Reaching here means values.toolchain_root is unresolved: `_apply`
355
+ # already replaced every occurrence when a value WAS resolved.
356
+ unresolved_toolchain = True
357
+ offset = start + len(token)
358
+ return substituted, unresolved_toolchain
359
+
360
+
361
+ def _sub_field(field: str, raw: str, values: TokenValues) -> str:
362
+ """Plan-level sites (`boardYaml`, `sharedArtefacts[]`) have no owning
363
+ slice to route a missing-toolchain skip to, so they keep the hard,
364
+ byte-identical `UnresolvedToolchainRoot` this pass always raised."""
365
+ substituted, unresolved_toolchain = _sub_field_lenient(field, raw, values)
366
+ if unresolved_toolchain:
367
+ raise UnresolvedToolchainRoot(field)
368
+ return substituted
369
+
370
+
371
+ def _record_first(field: str, unresolved: bool, current: str | None) -> str | None:
372
+ """Keep only the FIRST unresolved-toolchain field name; every field is
373
+ still substituted (and LeftoverToken-scanned) regardless, so a bug in a
374
+ LATER field of an already-demoted slice is never masked."""
375
+ return field if unresolved and current is None else current
376
+
377
+
378
+ def _substitute_artefact(field: str, art: dict[str, Any], values: TokenValues) -> dict[str, Any]:
379
+ out = dict(art)
380
+ out["path"] = _sub_field(f"{field}.path", art["path"], values)
381
+ out["contents"] = _sub_field(f"{field}.contents", art["contents"], values)
382
+ return out
383
+
384
+
385
+ def _substitute_artefact_lenient(
386
+ field: str,
387
+ art: dict[str, Any],
388
+ values: TokenValues,
389
+ deferred: DeferredPolicy | None = None,
390
+ ) -> tuple[dict[str, Any], str | None]:
391
+ """The slice-owned variant of `_substitute_artefact`: `configArtefacts`
392
+ live inside a slice, so an unresolved `${TOOLCHAIN_ROOT}` in one is
393
+ demotable too (the whole artefact list is stripped from a demoted
394
+ slice's output plan by the caller) -- a `${UNKNOWN}`, though, is still
395
+ `LeftoverToken`, scanned for here BEFORE the caller ever gets a chance to
396
+ strip the list."""
397
+ demoted_field: str | None = None
398
+
399
+ path_field = f"{field}.path"
400
+ path_sub, path_unresolved = _sub_field_lenient(path_field, art["path"], values)
401
+ demoted_field = _record_first(path_field, path_unresolved, demoted_field)
402
+
403
+ contents_field = f"{field}.contents"
404
+ contents_sub, contents_unresolved = _sub_field_lenient(
405
+ contents_field, art["contents"], values, deferred, path_sub
406
+ )
407
+ demoted_field = _record_first(contents_field, contents_unresolved, demoted_field)
408
+
409
+ out = dict(art)
410
+ out["path"] = path_sub
411
+ out["contents"] = contents_sub
412
+ return out, demoted_field
413
+
414
+
415
+ def _substitute_command_lenient(
416
+ base: str, cmd: SliceCommand, values: TokenValues
417
+ ) -> tuple[SliceCommand, str | None]:
418
+ """`base` is the plan path of the command being substituted --
419
+ `slices[N].command` for the slice's own, `slices[N].postCommands[M]` for
420
+ one of its post-build steps (tan-cli#550). Parameterised rather than
421
+ duplicated so a post-build step's `${SDK_ROOT}` / `${TOOLCHAIN_ROOT}` /
422
+ `${UNKNOWN}` is substituted, demoted and refused by exactly the same rules
423
+ as the slice's own command: those steps are spawned by the same executor,
424
+ so a token this pass left standing in one would be handed to
425
+ `subprocess` verbatim."""
426
+ demoted_field: str | None = None
427
+
428
+ new_cwd = cmd.cwd
429
+ if new_cwd is not None:
430
+ cwd_field = f"{base}.cwd"
431
+ new_cwd, cwd_unresolved = _sub_field_lenient(cwd_field, new_cwd, values)
432
+ demoted_field = _record_first(cwd_field, cwd_unresolved, demoted_field)
433
+
434
+ new_args: list[str] = []
435
+ for j, arg in enumerate(cmd.args):
436
+ field = f"{base}.args[{j}]"
437
+ sub, unresolved = _sub_field_lenient(field, arg, values)
438
+ new_args.append(sub)
439
+ demoted_field = _record_first(field, unresolved, demoted_field)
440
+
441
+ return replace(cmd, cwd=new_cwd, args=new_args), demoted_field
442
+
443
+
444
+ def _substitute_slice(
445
+ i: int, sl: Slice, values: TokenValues, deferred: DeferredPolicy | None = None
446
+ ) -> tuple[Slice, str | None]:
447
+ """Substitute every field of one slice, leniently for
448
+ `${TOOLCHAIN_ROOT}`: returns the first field that still names it
449
+ unresolved, if any, so the caller can build a `DemotedSlice` -- but a
450
+ `${UNKNOWN}` anywhere in the slice still raises `LeftoverToken`
451
+ immediately, ending the scan right there.
452
+
453
+ Field order mirrors `substitute_slice` in the Rust oracle exactly
454
+ (`buildDir`, then `configArtefacts`, then `env`, then `envAppendPath`,
455
+ then `command`), with `appDir` -- a field the Rust `BuildSlice` doesn't
456
+ carry -- inserted right after `buildDir`, the other bare slice-level path
457
+ field, and `postCommands` -- a field the oracle predates entirely
458
+ (alp-sdk #1344) -- last, immediately after the `command` it follows on the
459
+ wire and in execution order."""
460
+ demoted_field: str | None = None
461
+
462
+ build_dir_field = f"slices[{i}].buildDir"
463
+ build_dir, unresolved = _sub_field_lenient(build_dir_field, sl.build_dir, values)
464
+ demoted_field = _record_first(build_dir_field, unresolved, demoted_field)
465
+
466
+ # appDir is nullable (a Yocto slice built from the stock-image token has
467
+ # none) -- guarded the same shape as command.cwd below, not substituted
468
+ # when absent.
469
+ app_dir = sl.app_dir
470
+ if app_dir is not None:
471
+ app_dir_field = f"slices[{i}].appDir"
472
+ app_dir, unresolved = _sub_field_lenient(app_dir_field, app_dir, values)
473
+ demoted_field = _record_first(app_dir_field, unresolved, demoted_field)
474
+
475
+ new_artefacts: list[dict[str, Any]] = []
476
+ for j, art in enumerate(sl.config_artefacts):
477
+ base = f"slices[{i}].configArtefacts[{j}]"
478
+ new_art, art_demoted = _substitute_artefact_lenient(base, art, values, deferred)
479
+ new_artefacts.append(new_art)
480
+ if art_demoted is not None:
481
+ demoted_field = _record_first(art_demoted, True, demoted_field)
482
+
483
+ new_env: dict[str, str] = {}
484
+ for key, value in sorted(sl.env.items()):
485
+ field = f"slices[{i}].env.{key}"
486
+ sub, unresolved = _sub_field_lenient(field, value, values)
487
+ new_env[key] = sub
488
+ demoted_field = _record_first(field, unresolved, demoted_field)
489
+
490
+ new_env_append: dict[str, list[str]] = {}
491
+ for key, values_list in sorted(sl.env_append_path.items()):
492
+ new_list: list[str] = []
493
+ for k, value in enumerate(values_list):
494
+ field = f"slices[{i}].envAppendPath.{key}[{k}]"
495
+ sub, unresolved = _sub_field_lenient(field, value, values)
496
+ new_list.append(sub)
497
+ demoted_field = _record_first(field, unresolved, demoted_field)
498
+ new_env_append[key] = new_list
499
+
500
+ new_command = sl.command
501
+ if new_command is not None:
502
+ new_command, cmd_demoted = _substitute_command_lenient(
503
+ f"slices[{i}].command", new_command, values
504
+ )
505
+ if cmd_demoted is not None:
506
+ demoted_field = _record_first(cmd_demoted, True, demoted_field)
507
+
508
+ # tan-cli#550: the post-build steps are spawned by the same executor as
509
+ # `command`, so they are substituted by the same pass. Skipping them
510
+ # would hand `cmake --build ${PROJECT_ROOT}/...` to `subprocess` with the
511
+ # literal token still in it -- the executor has no second substitution
512
+ # pass to catch it, and neither the LeftoverToken nor the
513
+ # ${TOOLCHAIN_ROOT}-demotion guard would ever see the field.
514
+ new_post: list[SliceCommand] = []
515
+ for j, step in enumerate(sl.post_commands):
516
+ new_step, step_demoted = _substitute_command_lenient(
517
+ f"slices[{i}].postCommands[{j}]", step, values
518
+ )
519
+ new_post.append(new_step)
520
+ if step_demoted is not None:
521
+ demoted_field = _record_first(step_demoted, True, demoted_field)
522
+
523
+ new_slice = replace(
524
+ sl,
525
+ build_dir=build_dir,
526
+ app_dir=app_dir,
527
+ config_artefacts=new_artefacts,
528
+ env=new_env,
529
+ env_append_path=new_env_append,
530
+ command=new_command,
531
+ post_commands=tuple(new_post),
532
+ )
533
+ return new_slice, demoted_field
534
+
535
+
536
+ def substitute_plan_tokens(
537
+ plan: BuildPlan, values: TokenValues, deferred: DeferredPolicy | None = None
538
+ ) -> tuple[BuildPlan, list[DemotedSlice]]:
539
+ """ONE blind string-substitution pass over every path-bearing string
540
+ field of `plan`, swapping the four literal tokens for `values`. A no-op
541
+ -- byte-identical, empty demotion list -- when `plan.plan_path_mode` is
542
+ absent. `UnknownPlanPathMode` when it's present but not exactly
543
+ `"tokened"`.
544
+
545
+ After substitution, any leftover `${...}`-shaped token anywhere touched
546
+ fails the whole pass loudly -- EXCEPT a leftover `${TOOLCHAIN_ROOT}`
547
+ confined to a slice's own fields, which is reported via the returned
548
+ `DemotedSlice` list and has its `configArtefacts` stripped (nothing will
549
+ ever consume them this run), but the plan as a whole still succeeds. The
550
+ same token surviving in `boardYaml`/`sharedArtefacts[]` (no owning
551
+ slice) is still the hard `UnresolvedToolchainRoot` this pass always
552
+ raised.
553
+
554
+ `deferred` (tan-cli#1302) lets a leftover `${NAME}` that the policy admits
555
+ stay, byte-identical, in a slice's `configArtefacts[*].contents` -- and
556
+ nowhere else; `deferred.exempted` reports them. A demoted slice's
557
+ admissions are dropped with its stripped artefacts.
558
+
559
+ Ordering matches the Rust oracle: `boardYaml` first (hard site), then
560
+ every slice in order (each slice's own `configArtefacts` substituted
561
+ -- and stripped on demotion -- WITH it), then `sharedArtefacts` last
562
+ (hard site, cross-slice)."""
563
+ if plan.plan_path_mode is None:
564
+ # A fresh top-level object, matching Rust's `plan.clone()` -- returning
565
+ # `plan` itself would let a downstream mutation of the "output" plan
566
+ # silently alias back into the caller's input.
567
+ return replace(plan), []
568
+ if plan.plan_path_mode != PLAN_PATH_MODE_TOKENED:
569
+ raise UnknownPlanPathMode(plan.plan_path_mode)
570
+
571
+ # Hard site 1/2: no owning slice to demote to.
572
+ board_yaml = _sub_field("boardYaml", plan.board_yaml, values)
573
+
574
+ demoted: list[DemotedSlice] = []
575
+ new_slices: list[Slice] = []
576
+ for i, sl in enumerate(plan.slices):
577
+ mark = len(deferred.exempted) if deferred is not None else 0
578
+ new_slice, demoted_field = _substitute_slice(i, sl, values, deferred)
579
+ if demoted_field is not None:
580
+ if deferred is not None:
581
+ del deferred.exempted[mark:]
582
+ demoted.append(DemotedSlice(slice_index=i, core_id=sl.core_id, field=demoted_field))
583
+ # Strip AFTER the slice's fields (including these artefacts' own
584
+ # contents) have been fully scanned -- a ${UNKNOWN} inside a
585
+ # demoted slice's configArtefact contents must still hard-fail
586
+ # as LeftoverToken before it is ever cleared here.
587
+ new_slice = replace(new_slice, config_artefacts=[])
588
+ new_slices.append(new_slice)
589
+
590
+ # Hard site 2/2: sharedArtefacts are cross-slice -- same "no owning
591
+ # slice" reasoning as boardYaml above. Substituted AFTER all slices.
592
+ new_shared = [
593
+ _substitute_artefact(f"sharedArtefacts[{i}]", art, values)
594
+ for i, art in enumerate(plan.shared_artefacts)
595
+ ]
596
+
597
+ return (
598
+ replace(plan, board_yaml=board_yaml, slices=new_slices, shared_artefacts=new_shared),
599
+ demoted,
600
+ )