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
tan/core/scaffold.py ADDED
@@ -0,0 +1,2203 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """What files a `tan init` template lays down, and the disk diff/write around it.
3
+
4
+ Pure domain plus reads of tan's OWN template data. Mirrors
5
+ `crates/tan-core/src/wizard/` (`service/c_project.rs`, `service/vendored.rs`,
6
+ `service/example_catalog.rs`, `filesystem.rs`); the command shell that turns
7
+ these results into an envelope is `tan.commands.init_cmd`.
8
+
9
+ **No SDK checkout is consulted anywhere in here (I-32).** Five of the six
10
+ templates render a byte-for-byte capture of the SDK's own `--emit scaffold`
11
+ output, checked in under `tan/templates/vendored/` (see that package's
12
+ docstring); the sixth, `minimal-app`, is tan's OWN hand-generated stub and has
13
+ no SDK catalog entry at all. So a fresh customer with no alp-sdk anywhere gets
14
+ a real project, and -- the reason the contract fixture can exist -- `tan init
15
+ --template minimal-app --preview` is deterministic in an empty temp directory.
16
+ `--from-example` is the one init path that genuinely needs a checkout, because
17
+ it copies a directory out of one; it reports `init.sdk-root-unresolved`.
18
+
19
+ Deliberately NOT reimplemented here: the SDK's own
20
+ `scripts/alp_template.py::_scaffold_cmakelists()` regex rewrite of a scaffolded
21
+ `CMakeLists.txt`. That rewrite matches the *current* boilerplate text and
22
+ returns its input unchanged when it does not recognise the shape, so a second
23
+ implementation of it in tan is a silent `ALP_SDK_ROOT`-guess bug waiting for
24
+ the next SDK CMake change. It ran ONCE, at vendor time, and its output is what
25
+ `vendored/` holds.
26
+
27
+ **Hardware facts (the one documented exception).** `app_core_for_sku` maps a SKU
28
+ prefix to a core id, and `_family_bucket` maps one to a vendored tree. Both
29
+ descend from the Rust, both are the *only* hardware knowledge in the port, and
30
+ both exist because a scaffold has to name a core before any SDK is reachable --
31
+ `tan validate` re-checks the guess once one is. They now read ONE table
32
+ (`_SOM_FAMILIES`), which is what makes "the two derivations can never disagree"
33
+ true rather than aspirational: tan-cli#579 is exactly the bug where they did,
34
+ `app_core_for_sku` having grown an arm that `_family_bucket` never got, so a
35
+ `--som` of that family silently rendered the ALIF tree's content. A family the
36
+ table knows has no vendored tree is now REFUSED (`UnsupportedSomError` ->
37
+ `init.som-unsupported`), never rendered against another vendor's. Do not grow
38
+ this: no SKU list, no addresses, no pin names. Note what is NOT here as a result: the
39
+ OS is never selected (I-01/I-02) -- every generated core entry is `os: zephyr`
40
+ because the app source this scaffold writes is Zephyr source, and a scaffolded
41
+ `board.yaml` carries no top-level `os:` key at all.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ from tan.core.os_class import infer_runtime_for_core_id
47
+
48
+ import os
49
+ import re
50
+ from dataclasses import dataclass
51
+ from pathlib import Path, PureWindowsPath
52
+
53
+ from tan.core.fs_confine import PathEscapeError, resolve_confined
54
+ from tan.core.scaffold_selftest_identity import retarget_example_build_target_comment
55
+ from tan.core.scaffold_selftest_identity import retarget_selftest_soc_identity
56
+ from tan.core.scaffold_selftest_identity import retarget_selftest_som_identity
57
+ from tan.core.timestamp import generated_at_iso
58
+ from tan.templates import VENDORED_ROOT
59
+
60
+ #: Template ids, in registry order (`WizardTemplateId::as_str`). Wire contract:
61
+ #: `data.templateId` echoes one of these verbatim.
62
+ TEMPLATE_IDS = (
63
+ "minimal-app",
64
+ "zephyr-app",
65
+ "sensor-starter",
66
+ "iot-starter",
67
+ "edge-ai-starter",
68
+ "board-diagnostics",
69
+ "multicore-mailbox",
70
+ )
71
+
72
+ #: The template a non-interactive `tan init` with no `--template` gets.
73
+ #: `zephyr-app`, NOT `minimal-app` (tan-cli #97). Until tan-cli#309, TWO bugs
74
+ #: compounded: `board.yaml`'s `app: ./src` sent the planner's `_zephyr_app_dir`
75
+ #: straight at `src/CMakeLists.txt` (it has a `CMakeLists.txt` of its own, so
76
+ #: the parent-fallback that would have reached the real one never fired), and
77
+ #: THAT file called plain `add_executable(alp_app ...)` with no `find_package(
78
+ #: Zephyr ...)` at all -- so `west build -b <board> <project>/src` configured
79
+ #: and linked a genuine x86-64 host binary, silently, for a core declared
80
+ #: `os: zephyr`; the root `CMakeLists.txt` (dead code the whole time) was never
81
+ #: even the file at fault. tan-cli#309 fixed both: the generator itself
82
+ #: (`_minimal_app_root_cmake`/`_minimal_app_src_cmake` below) and `board.yaml`'s
83
+ #: `app:` (`_minimal_app_board_yaml`). zephyr-app stays the default regardless
84
+ #: -- that is a separate, still-live product choice (vendored from a real SDK
85
+ #: catalog entry vs. tan's own hand-generated stub), not something this fix
86
+ #: revisits. Do not "simplify" this back to the first registry entry.
87
+ DEFAULT_TEMPLATE_ID = "zephyr-app"
88
+
89
+ #: tan template id -> its vendored SDK scaffold-catalog directory.
90
+ #: `minimal-app` is absent deliberately: it is the one template with no SDK
91
+ #: catalog entry (`vendored/MANIFEST.md`, "minimal-app stays hand-generated").
92
+ _VENDORED_TEMPLATE_DIR = {
93
+ "zephyr-app": "minimal",
94
+ "sensor-starter": "sensor",
95
+ "iot-starter": "iot",
96
+ "edge-ai-starter": "edge-ai",
97
+ "board-diagnostics": "diagnostics",
98
+ # The only id that matches its SDK catalog id verbatim (tan-cli#864).
99
+ "multicore-mailbox": "multicore-mailbox",
100
+ }
101
+
102
+
103
+ def is_family_gated(template_id: str) -> bool:
104
+ """Whether `template_id` renders a family-specific vendored tree at all --
105
+ `False` for `minimal-app` ALONE, tan's one hand-generated, vendor-neutral
106
+ template (`plan_template_files`'s `template_id == "minimal-app"` early
107
+ return; it never reaches `_vendored_family`/`UnsupportedSomError`, so no
108
+ SoM family can ever refuse it). A public read of `_VENDORED_TEMPLATE_DIR`
109
+ rather than a second copy of its key set -- `explain_cmd` uses this to
110
+ decide whether `UNSUPPORTED_SOM_FAMILY_PREFIXES` applies to a given
111
+ template's `data.som` (tan-cli#866)."""
112
+ return template_id in _VENDORED_TEMPLATE_DIR
113
+
114
+
115
+ #: The SKU assumed when `--som` is absent (`tan_core::DEFAULT_SOM_SKU`).
116
+ DEFAULT_SOM_SKU = "E1M-AEN801"
117
+
118
+ #: Templates whose SDK catalog entry gates `supported.som_skus` to fewer SKUs
119
+ #: than tan vendors family trees for. Consulted BEFORE anything is planned, so
120
+ #: an unsupported `--som` is refused rather than quietly rendered against the
121
+ #: wrong tree.
122
+ #:
123
+ #: Was a single `IOT_STARTER_SUPPORTED_SKU` constant plus one hard-coded `if`
124
+ #: in `init_cmd`. tan-cli#864 added a second such template and measured both
125
+ #: ways the one-off failed to generalise: `--som E1M-AEN301` rendered the
126
+ #: AEN801 tree at `exitCode 0` (`_family_bucket` maps every unrecognised AEN
127
+ #: prefix onto the default family), and `--som E1M-V2N101` surfaced as
128
+ #: `init.template-unreadable` exit 5 -- "your tan installation is broken" for
129
+ #: a user whose `--som` was simply wrong.
130
+ #:
131
+ #: `iot-starter`: the CC3501E Wi-Fi transport is silicon-validated on this SKU
132
+ #: alone. `multicore-mailbox`: the SDK refuses to emit it for anything else --
133
+ #: `alp_project: multicore-mailbox: sku 'E1M-AEN301' is not supported
134
+ #: (supported: ['E1M-AEN801'])`, rc=1.
135
+ TEMPLATE_SUPPORTED_SKUS: dict[str, tuple[str, ...]] = {
136
+ "iot-starter": ("E1M-AEN801",),
137
+ "multicore-mailbox": ("E1M-AEN801",),
138
+ }
139
+
140
+ #: Vendored directory name per SoM family, `(alif_ensemble, renesas_v2n)` --
141
+ #: the two representative SKUs the SDK catalog declares.
142
+ _FAMILY_TREES = ("E1M-AEN801", "E1M-V2N101")
143
+
144
+ #: SoM family table: `(SKU prefix, app-core id, vendored tree)`, consulted in
145
+ #: order. A `None` tree means tan vendors NO scaffold for that family (no row
146
+ #: is `None` today).
147
+ #:
148
+ #: ONE table, read by BOTH `app_core_for_sku` and `_family_bucket`, which is
149
+ #: what makes this module's docstring claim -- "the two derivations can never
150
+ #: disagree" -- true by construction instead of by two hand-synced prefix
151
+ #: tests. tan-cli#579: they DID disagree. `app_core_for_sku` grew an arm
152
+ #: `_family_bucket` did not, so every SKU of that family took the latter's
153
+ #: `else` arm onto the Alif tree.
154
+ _SOM_FAMILIES: tuple[tuple[str, str, str | None], ...] = (
155
+ ("E1M-V2N", "m33_sm", _FAMILY_TREES[1]), # Renesas RZ/V2N
156
+ ("E1M-V2M", "m33_sm", _FAMILY_TREES[1]), # Renesas RZ/V2M -- shares the V2N tree
157
+ )
158
+
159
+ #: SKU prefixes `_SOM_FAMILIES` declares NO vendored tree for -- the exact
160
+ #: fact `_family_bucket`/`UnsupportedSomError`/`init.som-unsupported` gate a
161
+ #: `--som` on, DERIVED from that table rather than retyped, so a family added
162
+ #: to (or removed from) `_SOM_FAMILIES` updates this automatically instead of
163
+ #: needing a second hand-edit. tan-cli#866: `explain_cmd` reads this to
164
+ #: publish the SAME family-level refusal `tan init` already enforces as
165
+ #: structured `data.som` on `tan explain --template`, instead of the
166
+ #: hand-written "(E1M-AEN801 only)" prose that gave #866 its name -- the
167
+ #: exact drift `_SOM_FAMILIES` vs. `app_core_for_sku` risked before tan-cli#579
168
+ #: unified them into one table, now risked again between this table and any
169
+ #: description string that repeats a fact it already states.
170
+ UNSUPPORTED_SOM_FAMILY_PREFIXES: tuple[str, ...] = tuple(
171
+ prefix for prefix, _core, tree in _SOM_FAMILIES if tree is None
172
+ )
173
+
174
+ #: `(app core, tree)` for E1M-AEN* AND for any prefix `_SOM_FAMILIES` does not
175
+ #: recognise. Deliberately still a guess rather than a refusal: `tan init` is
176
+ #: SDK-free and cannot tell a future SKU from a typo, and guessing Alif is
177
+ #: what every version of this module has done. What matters is that BOTH
178
+ #: derivations take this arm together, so an unrecognised SKU still gets a
179
+ #: self-consistent scaffold; `tan validate` re-checks the guess once an SDK
180
+ #: resolves.
181
+ _DEFAULT_FAMILY: tuple[str, str] = ("m55_hp", _FAMILY_TREES[0])
182
+
183
+
184
+ def _som_family(sku: str) -> tuple[str, str | None]:
185
+ """`(app-core id, vendored tree)` for `sku` -- the single lookup both
186
+ hardware-adjacent derivations below are built on."""
187
+ for prefix, core, tree in _SOM_FAMILIES:
188
+ if sku.startswith(prefix):
189
+ return core, tree
190
+ return _DEFAULT_FAMILY
191
+
192
+
193
+ def _read_verbatim(path: Path) -> str:
194
+ """Read `path` as UTF-8 text with newlines UNTRANSLATED.
195
+
196
+ Every scaffold byte round-trips through here. Universal-newline mode would
197
+ fold a CRLF file's `\\r\\n` to `\\n` on read and expand it back on write, so on
198
+ Windows an LF template would be written CRLF (breaking byte-parity with the
199
+ Rust binary's output and with the vendored LF capture) and an existing CRLF
200
+ file would compare EQUAL to LF planned content -- a real on-disk difference
201
+ reported as `unchanged`.
202
+
203
+ `open(newline="")`, not `Path.read_text(newline=...)`: that keyword landed in
204
+ 3.13 and this package's floor is 3.12.
205
+
206
+ Raises `OSError` for a non-UTF-8 file too (tan-cli#415): `UnicodeDecodeError`
207
+ is a `ValueError`, not an `OSError`, so a bare `except OSError` around a
208
+ call to this function -- both call sites below use exactly that -- would
209
+ otherwise let a corrupt vendored tree escape as an unhandled traceback
210
+ instead of the `TemplateDataError` they already raise for every other way
211
+ the read can fail. Folded in here, once, rather than duplicated at each
212
+ call site: mirrors Rust's own `read_to_string`, which returns an
213
+ `io::Error` (kind `InvalidData`) for invalid UTF-8 rather than a distinct
214
+ error type (the same equivalence `kconfig_cmd.py`'s `_resolve_core`
215
+ documents for the identical reason).
216
+
217
+ The synthesised `OSError` NAMES THE FILE (tan-cli#494 defect 4). Python's
218
+ own `open()` interpolates the filename into every `OSError` it raises, so
219
+ the permission-denied branch always reported a path; the codec branch --
220
+ the one a stray binary actually trips -- raised a bare
221
+ `UnicodeDecodeError` whose `str()` is only `'utf-8' codec can't decode
222
+ byte 0xff in position 88: invalid start byte`. The caller wraps that
223
+ verbatim into `init.example-unreadable`, leaving the customer with a
224
+ 600-file tree and nothing to act on. The oracle's own message
225
+ (`read_to_string` -> `io::Error`) names the path, so this restores parity
226
+ as well as sense.
227
+ """
228
+ try:
229
+ with path.open("r", encoding="utf-8", newline="") as handle:
230
+ return handle.read()
231
+ except UnicodeDecodeError as err:
232
+ raise OSError(f"{path}: {err}") from err
233
+
234
+
235
+ def _write_verbatim(path: Path, content: str) -> None:
236
+ """The write half of [`_read_verbatim`] -- same reasoning, same keyword."""
237
+ with path.open("w", encoding="utf-8", newline="") as handle:
238
+ handle.write(content)
239
+
240
+
241
+ class TemplateDataError(Exception):
242
+ """tan's own vendored template tree is missing or unreadable.
243
+
244
+ Not a user error and not reachable from a correct build: the tree ships
245
+ inside the package. It IS reachable from a frozen binary built without the
246
+ `--add-data` that carries it, which is why this is an exception with its own
247
+ issue code instead of an `IOError` escaping as a traceback.
248
+ """
249
+
250
+
251
+ class UnsupportedSomError(Exception):
252
+ """`--som` names a SoM family tan vendors no scaffold tree for, so the
253
+ requested template cannot be rendered for it (tan-cli#579).
254
+
255
+ A USER error (exit 2), not a broken installation: the tree is not missing,
256
+ it was never captured, because alp-sdk's own scaffold catalog declares no
257
+ entry for that family. `init_cmd` reports it as `init.som-unsupported`.
258
+
259
+ Refusing is the whole point, and it is a DELIBERATE divergence from the
260
+ frozen v0.4.1 oracle, which exits 0 with `issues: []` and writes the Alif
261
+ tree for a family it has no tree for. The two alternatives were weighed and
262
+ rejected:
263
+
264
+ * **Vendor a tree for it.** Not tan's to write. `templates/vendored/` is a
265
+ byte-for-byte capture of alp-sdk's `--emit scaffold` output (see its
266
+ `MANIFEST.md`), and `tests/parity/scaffold_byte_parity.py` re-runs the
267
+ live emit against a reachable checkout and fails on drift. A tree
268
+ hand-authored here would be tan inventing hardware facts (I-26) AND
269
+ would fail that gate the moment an SDK is reachable. When alp-sdk adds
270
+ the catalog entry, this refusal retires by filling in one table row in
271
+ `_SOM_FAMILIES`.
272
+ * **Fall back with a loud advisory.** The wrong-vendor project still
273
+ lands on disk, gets committed, and stays wrong: its `CMakeLists.txt`
274
+ asks the SDK loader for the tree family's core while the `board.yaml`
275
+ beside it declares the target's, its README's `west build -b ...` line
276
+ names another vendor's board target, and its `chips:`/`preset:` describe
277
+ another module's BOM. A one-line warning on one terminal, once, does not
278
+ undo that.
279
+
280
+ Scoped to the VENDORED templates: `minimal-app` is tan's own
281
+ hand-generated, vendor-neutral stub and stays available for every SKU --
282
+ which is what makes refusing cheap rather than a dead end.
283
+ """
284
+
285
+ def __init__(self, template_id: str, sku: str) -> None:
286
+ super().__init__(
287
+ f"Template '{template_id}' has no vendored scaffold for SoM '{sku}'. "
288
+ f"tan ships scaffold trees for the E1M-AEN* and E1M-V2N*/E1M-V2M* "
289
+ f"families only; rendering one of those for this SoM would write "
290
+ f"another vendor's content under your SKU -- its board.yaml "
291
+ f"`preset:`/`chips:`, its README's `west build -b ...` target, and a "
292
+ f"CMakeLists.txt pinned to that family's core. Use `--template "
293
+ f"minimal-app`, which is vendor-neutral and scaffolds any SoM, or "
294
+ f"`--from-example <example>` with an alp-sdk checkout."
295
+ )
296
+ self.template_id = template_id
297
+ self.sku = sku
298
+
299
+
300
+ class SomBlockUnsupportedError(Exception):
301
+ """Base for every reason `vendored_som`/`retarget_board_yaml_som` REFUSE
302
+ a `som:` block instead of silently discarding `--som` -- catch THIS at
303
+ every call site (`init_cmd.py`), not either leaf below, so a THIRD leaf
304
+ added for the next spelling needs no call site touched outside this
305
+ module.
306
+
307
+ tan-cli#1041 review: this class did not exist until the amendment
308
+ widened tan-cli#1041 from three spellings to six and two of them turned
309
+ out to need a message that was not about flow style at all
310
+ (`UnreadableSomBlockError`, below) -- `FlowStyleSomError` alone was
311
+ already the exact shape `init_cmd.py`'s three `except` clauses wanted,
312
+ so splitting the raise from the catch (a common base, not a rename) was
313
+ the smaller, safer change.
314
+ """
315
+
316
+
317
+ class FlowStyleSomError(SomBlockUnsupportedError):
318
+ """The board.yaml's top-level `som:` line opens a YAML FLOW mapping
319
+ (`som: {sku: ..., hw_rev: ...}`, valid YAML but on one physical line)
320
+ rather than the BLOCK style (`som:` alone, `sku:`/`hw_rev:` indented
321
+ beneath it) `vendored_som`/`retarget_board_yaml_som` understand.
322
+
323
+ tan-cli#1029, `Refs #1008`. This module is deliberately NOT a YAML
324
+ parser (see its own module docstring): it scans line-by-line and edits
325
+ in place, which is what lets a hand-authored comment, a wrapped comment
326
+ block, or column alignment survive a `--som` retarget byte-for-byte.
327
+ Extending that scan to also parse a flow mapping correctly -- telling a
328
+ comma inside a quoted value apart from one separating two keys, an
329
+ escaped brace from a real one, a `{`/`}` that itself appears inside a
330
+ quoted string -- needs a real YAML parser, not a seventh line-oriented
331
+ special case bolted onto tan-cli#1008's own six-round history of exactly
332
+ that kind of case. Refusing loudly is the deliberate choice over
333
+ supporting it: the alternative was `vendored_som` silently reporting
334
+ `(None, None)` and `retarget_board_yaml_som` silently returning its
335
+ input byte-for-byte unchanged, so `--som` was discarded with `issues:
336
+ []` and exit 0 -- indistinguishable from success.
337
+
338
+ Raised from `vendored_som` (the reader) the moment it sees this shape;
339
+ `retarget_board_yaml_som` (the writer) inherits the same raise for free,
340
+ since it calls `vendored_som` first -- one rule, in one place, so the
341
+ two cannot diverge on which `som:` lines this module refuses, the same
342
+ shape `top_level_key_name`/`_is_som_key_line`/`_split_child_key` already
343
+ hold for tan-cli#1008.
344
+
345
+ Only covers the SAME-LINE flow shape (`som: {...}`, `som: &s {...}`,
346
+ `som: !!map {...}`); a flow mapping whose `{` opens on the NEXT physical
347
+ line is `UnreadableSomBlockError`'s job (tan-cli#1041) -- this class's
348
+ own detector (`_som_flow_style_body`) only ever inspects the `som:`
349
+ line itself, by design (see that function's docstring).
350
+
351
+ Reachability (tan-cli#1029): 0 of the SDK's 100 tracked `board.yaml`
352
+ files and 0 vendored templates use a flow-style `som:` mapping --
353
+ hand-authored only, same as the spaced/quoted shapes tan-cli#1008 rounds
354
+ 5-6 fixed.
355
+ """
356
+
357
+ def __init__(self, flow_body: str) -> None:
358
+ super().__init__(
359
+ f"This board.yaml's `som:` block is written in YAML flow style "
360
+ f"(`som: {flow_body}`), which tan's board.yaml scaffolder does "
361
+ f"not parse. Rewrite the `som:` block in block style (`sku:`/"
362
+ f"`hw_rev:` each on their own indented line beneath `som:`) and "
363
+ f"try again -- keep any `&anchor`/`!tag` the `som:` key itself "
364
+ f"carries where it is: dropping it would silently break a `*alias` "
365
+ f"referring to it elsewhere in the file."
366
+ )
367
+ self.flow_body = flow_body
368
+
369
+
370
+ class UnreadableSomBlockError(SomBlockUnsupportedError):
371
+ """`vendored_som` recognized a top-level `som:` entry -- either the
372
+ line-oriented scan matched a literal `som:`/`"som":` line
373
+ (`_is_som_key_line`), or a real YAML parse resolved one that no literal
374
+ `som:` text exists for at all -- but found no LITERAL `sku:` line
375
+ nested beneath it to read or rewrite.
376
+
377
+ tan-cli#1041 (the amendment). Three of the amendment's six named
378
+ spellings land here, all sharing the identical root cause -- a `som:`
379
+ (or an effective one, produced by a document-root merge) whose `sku:`
380
+ is not a plain indented line this scanner can find -- rather than three
381
+ separate detectors for three separate reasons:
382
+
383
+ - a YAML ALIAS (`som: *base`): the whole value is a reference to a
384
+ mapping defined elsewhere; no child lines follow the `som:` line at
385
+ all.
386
+ - a MERGE key (`<<: *base`) inside the `som:` block with no explicit
387
+ `sku:` override alongside it: the real `sku:` value lives in
388
+ whatever `base` points at, not in this block. (A merge key WITH an
389
+ explicit override retargets correctly, same as any other block-style
390
+ `som:` -- the override line is a literal `sku:` this scan already
391
+ finds, so it never reaches this class at all.)
392
+ - a MERGE key at the DOCUMENT ROOT (`<<: *base`, sibling to `som:`
393
+ rather than nested under it) whose target itself defines `som:`: no
394
+ `som:` text exists anywhere in the file for the line scan to find, so
395
+ `vendored_som`'s own scan reports nothing at all -- this is the one
396
+ shape a real `yaml.safe_load` backstop is needed for (see
397
+ `vendored_som`'s docstring), not the line scan.
398
+ - a FLOW mapping split across MORE than one physical line (`som:\\n
399
+ {sku: ..., hw_rev: ...}`, with or without a leading comment or
400
+ `&anchor` on the `som:` line itself): `FlowStyleSomError`'s own
401
+ detector only ever looks at the `som:` line, by design, so this shape
402
+ falls straight through it and is caught here instead, by the same
403
+ "a `sku:` line was never found" signal every other shape in this list
404
+ trips.
405
+
406
+ Deliberately ONE generic message rather than a shape-specific one per
407
+ bullet above: naming "alias" vs "merge key" vs "multi-line flow"
408
+ correctly would need to re-derive which one this actually is -- the
409
+ exact per-spelling special-casing tan-cli#1008 (six rounds) and
410
+ tan-cli#1035 (three rounds) already show costs more than it is worth.
411
+ What every shape above has in common -- a `som:` this module can see
412
+ exists, paired with no `sku:` line it can find inside it -- is exactly
413
+ what the message says, and is enough to tell a customer what to fix.
414
+
415
+ Raised from `vendored_som`; `retarget_board_yaml_som` inherits it the
416
+ same way it already inherits `FlowStyleSomError`, by calling
417
+ `vendored_som` first.
418
+ """
419
+
420
+ def __init__(self) -> None:
421
+ super().__init__(
422
+ "This board.yaml's `som:` block could not be read: tan found a "
423
+ "`som:` key but no literal `sku:` line indented directly "
424
+ "beneath it. A YAML `*alias`, a `<<:` merge key with no "
425
+ "explicit `sku:` override (whether inside the `som:` block or "
426
+ "at the top of the file), and a flow mapping split across more "
427
+ "than one line all look like this to tan's board.yaml "
428
+ "scaffolder, which edits `som:` blocks line-by-line rather "
429
+ "than as a parsed document. Rewrite the `som:` block in block "
430
+ "style, with `sku:` (and `hw_rev:`, if set) each written as "
431
+ "their own literal line indented beneath `som:`, and try "
432
+ "again."
433
+ )
434
+
435
+
436
+ class ExampleReadError(Exception):
437
+ """`--from-example`'s source directory is missing, or a file in it is not
438
+ readable UTF-8 text. `not_found` separates the two: a missing example is
439
+ the user's typo (exit 2), an unreadable one is a runtime failure (exit 1)."""
440
+
441
+ def __init__(self, message: str, *, not_found: bool) -> None:
442
+ super().__init__(message)
443
+ self.not_found = not_found
444
+
445
+
446
+ @dataclass(frozen=True)
447
+ class PlannedFile:
448
+ """One file the template will lay down: its project-relative, forward-slash
449
+ path and its exact content."""
450
+
451
+ relative_path: str
452
+ content: str
453
+
454
+
455
+ @dataclass(frozen=True)
456
+ class FileChange:
457
+ #: `new` | `update` | `unchanged` -- `WizardFileChangeKind::as_str`, wire
458
+ #: contract via `data.fileChanges[].kind`.
459
+ relative_path: str
460
+ kind: str
461
+
462
+
463
+ @dataclass(frozen=True)
464
+ class WriteResult:
465
+ written: list[str]
466
+ unchanged: list[str]
467
+
468
+
469
+ class ScaffoldWriteError(Exception):
470
+ """A write failed part-way. Carries the `partial` result accumulated before
471
+ the failure: reporting `written: []` for a project that really is half on
472
+ disk leaves a consumer with no idea what to clean up or reopen."""
473
+
474
+ def __init__(self, message: str, partial: WriteResult) -> None:
475
+ super().__init__(message)
476
+ self.partial = partial
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # Path guard
481
+ # ---------------------------------------------------------------------------
482
+
483
+
484
+ def is_plain_relative(raw: str) -> bool:
485
+ """True when `raw` is a plain relative path: non-empty, no `.`/`..`, not
486
+ absolute, not drive-rooted or root-relative.
487
+
488
+ `tan_core::is_plain_relative`. Guards the two inputs that decide WHERE
489
+ files come from and go to: `--name` (joined onto the destination, so an
490
+ unchecked `..` or absolute value put the project root -- and with `--force`
491
+ an overwrite target -- anywhere the process can write) and `--from-example`
492
+ (joined onto the SDK `examples/` root).
493
+
494
+ Evaluated with WINDOWS path semantics on every platform: `C:foo` and `\\x`
495
+ are not `is_absolute()` on Windows yet still escape a join, and being
496
+ stricter than POSIX needs to be costs nothing -- no template or example
497
+ path legitimately contains a backslash or a drive letter.
498
+
499
+ A bare `.` and a leading `./` are rejected, not normalised away: `.` made
500
+ `--from-example .` clear this guard, join back to `examples/` itself, and
501
+ copy the SDK's entire examples tree in as one "example".
502
+ """
503
+ if not raw or raw[0] in "/\\":
504
+ return False
505
+ if PureWindowsPath(raw).drive:
506
+ return False
507
+ segments = [s for s in re.split(r"[\\/]", raw) if s]
508
+ if not segments:
509
+ return False
510
+ return all(s not in (".", "..") for s in segments)
511
+
512
+
513
+ # ---------------------------------------------------------------------------
514
+ # Hardware-adjacent derivations (see the module docstring)
515
+ # ---------------------------------------------------------------------------
516
+
517
+
518
+ def app_core_for_sku(sku: str) -> str:
519
+ """Canonical Zephyr app-core id for a SoM family. `tan init` is SDK-free,
520
+ so this maps by SKU prefix (`_SOM_FAMILIES`); `tan validate` re-checks it
521
+ against the real SoM catalogue once an SDK resolves. Read by the
522
+ hand-generated `minimal-app` template and by `retarget_board_yaml_cores`,
523
+ and derived from the SAME table `_family_bucket` reads, so the two
524
+ derivations cannot disagree (tan-cli#579)."""
525
+ return _som_family(sku)[0]
526
+
527
+
528
+ def _family_bucket(sku: str) -> str | None:
529
+ """The vendored tree directory for `sku`, or `None` when tan vendors no
530
+ scaffold for that SoM family -- `_vendored_files` turns that into an
531
+ `UnsupportedSomError`.
532
+
533
+ tan-cli#579: this used to be
534
+ `_FAMILY_TREES[1] if sku.startswith(("E1M-V2N","E1M-V2M")) else
535
+ _FAMILY_TREES[0]`, so a family `app_core_for_sku` right above
536
+ already knew (with no tree) fell down the `else` arm and got the ALIF tree's content,
537
+ at `ok: true` / exit 0 / `issues: []`. An unrecognised prefix still takes
538
+ the Alif default, in both derivations at once (see `_DEFAULT_FAMILY`);
539
+ only a family tan positively knows it has no tree for is refused."""
540
+ return _som_family(sku)[1]
541
+
542
+
543
+ # ---------------------------------------------------------------------------
544
+ # board.yaml SoM retargeting
545
+ # ---------------------------------------------------------------------------
546
+
547
+
548
+ def _split_cr(line: str) -> tuple[str, str]:
549
+ """Split a `content.split("\\n")` element into `(body, cr)`, where `cr` is
550
+ the `"\\r"` a CRLF source leaves on the end of every line and `""` for LF.
551
+
552
+ tan-cli#404: the `\\r` is a LINE TERMINATOR, never content, and every
553
+ character-level decision below (is the tail a comment? where does the value
554
+ token end? is there anything after `sku:` at all?) got it wrong when it was
555
+ left attached. Stripping it once, up front, is what makes those decisions
556
+ terminator-insensitive; the caller re-appends it verbatim, so a CRLF file
557
+ goes back out CRLF and byte-parity with `_read_verbatim` holds.
558
+ """
559
+ return (line[:-1], "\r") if line.endswith("\r") else (line, "")
560
+
561
+
562
+ def _retargeted_sku_line(indent: str, after_key: str, sku: str) -> tuple[str, bool]:
563
+ """Rewrite one `sku:` line body onto `sku`, given `after_key` -- everything
564
+ after the child key's colon, as returned by [`_split_child_key`] (so a
565
+ space before the colon in the SOURCE line, `sku : x`, is already handled
566
+ by the caller; this function only ever emits a normalized `sku:`, no
567
+ space, regardless of how the source was spaced). Returns the new body (no
568
+ line terminator -- see [`_split_cr`]) and whether a trailing COMMENT was
569
+ dropped, which is what tells the caller a wrapped comment block has been
570
+ opened and its continuation lines must go too.
571
+ """
572
+ stripped = after_key.lstrip(" \t")
573
+ leading_ws = after_key[: len(after_key) - len(stripped)]
574
+ if not stripped or stripped.startswith("#"):
575
+ # `sku:` with nothing after it, or `sku: # comment` with a comment but
576
+ # no value. Splicing at the first whitespace run would either glue the
577
+ # value onto `sku:` (read back as a scalar, not a mapping entry) or eat
578
+ # the `#` into it. A `sku:` with no value names no SoM, so its comment
579
+ # is not stale and stays.
580
+ return f"{indent}sku: {sku}{after_key}", False
581
+ # `[ \t]`, not `\s`: the value token ends at a space or a tab and at nothing
582
+ # else. `\s` also matched the `\r` of a CRLF line, so the terminator was
583
+ # captured into `tail` and thrown away with it -- one mixed-EOL line in the
584
+ # very file the customer is about to edit (tan-cli#404).
585
+ match = re.search(r"[ \t]", stripped)
586
+ value = stripped[: match.start()] if match else stripped
587
+ tail = stripped[match.start() :] if match else ""
588
+ if value == sku:
589
+ return f"{indent}sku:{leading_ws}{sku}{tail}", False
590
+ return f"{indent}sku:{leading_ws}{sku}", tail.lstrip(" \t").startswith("#")
591
+
592
+
593
+ def _is_wrapped_comment_line(body: str, sku_indent: str) -> bool:
594
+ """True when `body` is a comment-only line indented PAST `sku_indent` --
595
+ i.e. a continuation of the comment the `sku:` line opened, not a comment
596
+ documenting whatever comes next (tan-cli#404). A blank line ends the block:
597
+ it is not a comment, and a comment resuming after one is a new thought."""
598
+ stripped = body.lstrip(" \t")
599
+ if not stripped.startswith("#"):
600
+ return False
601
+ return len(body) - len(stripped) > len(sku_indent)
602
+
603
+
604
+ def top_level_key_name(text: str) -> str:
605
+ """Everything in `text` before its first `:`, whitespace-trimmed -- the
606
+ key name of a `<key>:` mapping line, tolerating a space (or more) before
607
+ the colon (`som :` is valid YAML: `yaml.safe_load("som :\n sku: x\n")`
608
+ -> `{"som": {"sku": "x"}}`). Returns the whole (trimmed) `text` when
609
+ there is no `:` at all, matching `str.split(":", 1)[0]`'s own behaviour
610
+ on a colon-less string.
611
+
612
+ tan-cli#1008 review round 5: this repo now has THREE independent readers
613
+ of "what is this line's top-level key" --
614
+ `generate_cmd._scan_som_sku` (`stripped.split(":", 1)[0].strip() ==
615
+ "som"`), `bootstrap_cmd._scan_board_slice` (`key =
616
+ stripped.partition(":")[0].strip()`), and this file's own
617
+ `_is_som_key_line` (round 4's fix, which used a STRICTER
618
+ `startswith(f"{key}:")` that rejected `som :`). That divergence is
619
+ exactly how round 4's own bug happened one level up (`_is_som_key_line`
620
+ vs `vendored_som`'s pre-round-4 exact-match). One rule, in one place;
621
+ `generate_cmd`/`bootstrap_cmd` import and call THIS instead of keeping
622
+ their own copies.
623
+
624
+ tan-cli#1041: also strips a single layer of matching `'`/`"` quotes
625
+ around the key, so a QUOTED top-level key (`"som":`, `'som':`) resolves
626
+ to the same name as its bare spelling -- `yaml.safe_load` already treats
627
+ them identically (`{"som": ...}` either way), and this was the one
628
+ spelling of the six tan-cli#1041 named where the fix belongs HERE
629
+ (widening the one shared rule) rather than in a refusal: nothing about a
630
+ quoted key stops the line-oriented writer beneath it from working, so
631
+ unlike its five siblings this shape should retarget correctly, not just
632
+ refuse loudly. Only fires when the closing quote is immediately followed
633
+ by (optional whitespace then) a `:` -- i.e. this really is `"key":`, not
634
+ some other quoted scalar that merely starts the text -- so a colon-less
635
+ string's contract above (return the whole trimmed text) still holds for
636
+ everything else, quoted or not. No escape handling: `som`/`sku`/`hw_rev`,
637
+ the only keys any caller of this function ever tests against, contain no
638
+ character that would ever need one.
639
+ """
640
+ stripped = text.strip()
641
+ quote = stripped[:1]
642
+ if quote in ("'", '"'):
643
+ end = stripped.find(quote, 1)
644
+ if end != -1 and stripped[end + 1 :].lstrip(" \t")[:1] == ":":
645
+ return stripped[1:end]
646
+ return text.split(":", 1)[0].strip()
647
+
648
+
649
+ def _is_som_key_line(body: str) -> bool:
650
+ """Whether `body` is the top-level `som:` line -- unindented, with
651
+ `som` as [`top_level_key_name`]'s answer for it (so `som :`, a trailing
652
+ comment, and trailing whitespace all still match -- only indentation and
653
+ the key name itself are checked).
654
+
655
+ tan-cli#1008 review round 4: `retarget_board_yaml_som`'s scan and
656
+ `vendored_som`'s reader used to apply DIFFERENT rules for this -- the
657
+ scan matched `trimmed.startswith("som:")` (tolerant of a trailing
658
+ comment/whitespace), the reader matched an exact `body == "som:"`
659
+ (strict) -- so a `som:` line carrying a trailing comment or trailing
660
+ whitespace was recognized by the scan (which retargeted `sku:` inside
661
+ it) but not by the reader (which then reported `existing_sku` as
662
+ `None`, so the sibling `hw_rev:`-drop this file exists for silently
663
+ stood down). One predicate, used by both callers, so a third caller
664
+ cannot diverge from either again.
665
+ """
666
+ return bool(body) and body[0] not in " \t" and top_level_key_name(body) == "som"
667
+
668
+
669
+ def _split_child_key(trimmed: str) -> tuple[str, str] | None:
670
+ """Split an already-indent-stripped CHILD mapping line (one nested under
671
+ a top-level block, e.g. a `som:` block's `sku:`/`hw_rev:`) into
672
+ `(key, after_colon)`. `key` is literally [`top_level_key_name`]'s answer
673
+ for `trimmed` -- not a second implementation of its rule -- so a space
674
+ before the colon (`sku :`, `hw_rev :`) AND a quoted key (`"sku":`,
675
+ `'hw_rev':`) are both tolerated here exactly as they are at the
676
+ top-level `som:` line, because both call sites now run the same code.
677
+ `None` when `trimmed` has no `:` at all (not a mapping line).
678
+
679
+ tan-cli#1008 review round 6: `vendored_som` (the reader) and
680
+ `retarget_board_yaml_som` (the writer) applied DIFFERENT rules for "is
681
+ this line a `sku:`/`hw_rev:` child" -- the reader used a tolerant
682
+ `trimmed.partition(":")`, the writer an exact
683
+ `trimmed.startswith("hw_rev:")`/`startswith("sku:")`. Round 5 already
684
+ fixed this exact class of divergence one level up for the top-level
685
+ `som:` line (`_is_som_key_line`); it did not carry the fix down to the
686
+ child keys underneath it. Concretely: on `hw_rev : r2` the reader saw
687
+ the key (arming the cross-family `drop_hw_rev` logic) while the writer's
688
+ `startswith` did not match the line at all, so a hand-authored spaced
689
+ `hw_rev :`/`sku :` line was either left un-dropped (major 1) or -- worse
690
+ -- had its `hw_rev:` deleted by the drop logic while its own `sku :`
691
+ line went unretargeted, since the writer's `sku:` `startswith` check
692
+ also failed to match (major 2, a silent `--som` no-op). One rule, in one
693
+ place, used by both the reader and the writer, so they cannot diverge
694
+ again.
695
+
696
+ tan-cli#1060 review: this docstring used to CLAIM the top-level rule
697
+ applied here while the body stayed a bare `trimmed.partition(":")` that
698
+ never unquoted anything -- true the day round 6 landed, false the moment
699
+ tan-cli#1041 taught `top_level_key_name` to strip a quoted key's `'`/`"`
700
+ pair and this function did not follow. Consequence: `"som":` (a
701
+ top-level quoted key) retargeted correctly, but the exact same quoting
702
+ one level down (`som:` with a `"sku":`/`'hw_rev':` child) did not --
703
+ `found_sku_key` never set, `entered_som and not found_sku_key` firing
704
+ `UnreadableSomBlockError` for a shape that has a perfectly literal
705
+ `sku:` line to rewrite, precisely the top-level-vs-child divergence round
706
+ 6 exists to prevent, reopened one call site later. Calling
707
+ `top_level_key_name` here rather than re-inlining its rule is what makes
708
+ that divergence structurally impossible rather than merely undocumented:
709
+ a future change to the quoting rule only has one function to change.
710
+ """
711
+ if ":" not in trimmed:
712
+ return None
713
+ _key, _colon, rest = trimmed.partition(":")
714
+ return top_level_key_name(trimmed), rest
715
+
716
+
717
+ #: A single leading YAML node-property token: an anchor (`&name`) or a tag
718
+ #: (`!tag`, `!!type`, or a verbatim `!<...>` URI) -- whatever character
719
+ #: sequence starts at `&`/`!` and runs to the next whitespace. Doesn't
720
+ #: validate the anchor name or tag URI; that is not this scanner's job, only
721
+ #: recognizing that a property token, rather than the value, sits here.
722
+ _NODE_PROPERTY_RE = re.compile(r"^[&!]\S+")
723
+
724
+
725
+ def _strip_yaml_node_properties(text: str) -> str:
726
+ """Strip zero or more leading YAML node-property tokens (an anchor
727
+ `&name`, a tag `!tag`/`!!type`, or both together, in EITHER order,
728
+ separated by whitespace) from `text`, returning whatever remains after
729
+ the last one (with any following whitespace also stripped).
730
+
731
+ tan-cli#1035 review round 2 major: separates "what decorations precede
732
+ the value" from "is the value a flow mapping" -- the two concerns
733
+ `_som_flow_style_body` conflated when it tested `stripped.startswith("{")`
734
+ against text that could still carry an anchor/tag prefix. YAML allows an
735
+ anchor and a tag together, in either order (`&s !!map {...}` and
736
+ `!!map &s {...}` are both valid), and either alone; this strips as many
737
+ property tokens as are present, so `_som_flow_style_body`'s own `{` test
738
+ runs against the actual value, never against a property token that
739
+ happens to not start with `{`.
740
+ """
741
+ remainder = text
742
+ while True:
743
+ match = _NODE_PROPERTY_RE.match(remainder)
744
+ if not match:
745
+ return remainder
746
+ remainder = remainder[match.end() :].lstrip(" \t")
747
+
748
+
749
+ def _som_flow_style_body(body: str) -> str | None:
750
+ """When `body` is the top-level `som:` line ([`_is_som_key_line`]) AND
751
+ what follows its colon -- past any anchor/tag prefix
752
+ ([`_strip_yaml_node_properties`]) -- opens a YAML FLOW mapping (`som:
753
+ {sku: ..., hw_rev: ...}`, `som: &s {sku: ...}`, `som: !!map {sku: ...}`)
754
+ -- i.e. the first non-blank character there is `{` -- return the ORIGINAL
755
+ trailing text (properties included) verbatim, for the error message.
756
+ `None` for an ordinary block-style `som:` line: nothing, only a trailing
757
+ comment, or a bare anchor/tag (`som: &s`, `som: !!map`, `som: &s !!map`)
758
+ with nothing flow-shaped after it, all of which are valid YAML that still
759
+ opens a BLOCK mapping on the lines beneath it, not a non-`som:` line.
760
+
761
+ tan-cli#1029: the one signal both `vendored_som` and
762
+ `retarget_board_yaml_som` need to refuse a shape neither actually reads
763
+ (see [`FlowStyleSomError`]) -- called from `vendored_som` alone; the
764
+ writer inherits the same refusal by calling the reader first, so the
765
+ two share this one rule rather than each guessing independently, the
766
+ same shape `top_level_key_name`/`_is_som_key_line`/`_split_child_key`
767
+ already hold for tan-cli#1008.
768
+
769
+ tan-cli#1035 review round 2 major 1: an earlier version of this function
770
+ treated ANY non-comment content after the colon as flow style, which
771
+ also caught `som: &s` (an anchor) and `som: !!map` (a tag) -- both valid
772
+ BLOCK-style `som:` lines -- so it was narrowed to `stripped.startswith
773
+ ("{")`. That narrowing over-corrected: it tested the RAW text after the
774
+ colon, so `som: &s {sku: ...}` and `som: !!map {sku: ...}` -- genuine
775
+ flow mappings carrying an anchor/tag prefix -- no longer started with
776
+ `{` and escaped the detector entirely, reopening tan-cli#1029's own
777
+ silent-`--som`-discard symptom on exactly the shape this function exists
778
+ to refuse. Stripping the anchor/tag prefix FIRST, then testing the
779
+ remainder, is what lets both prior fixes stay true at once: a bare
780
+ anchor/tag still falls through to the block path (nothing left to test
781
+ after stripping), and an anchor/tag ahead of a real `{` is still caught
782
+ (something starting with `{` left after stripping).
783
+ """
784
+ if not _is_som_key_line(body):
785
+ return None
786
+ _key, _colon, rest = body.partition(":")
787
+ stripped = rest.lstrip(" \t")
788
+ if not stripped or stripped.startswith("#"):
789
+ return None
790
+ remainder = _strip_yaml_node_properties(stripped)
791
+ if not remainder or remainder.startswith("#"):
792
+ return None
793
+ if not remainder.startswith("{"):
794
+ return None
795
+ return stripped
796
+
797
+
798
+ #: Mirrors `scripts/alp_project_loader._SKU_FAMILY` -- deliberately
799
+ #: duplicated rather than imported: this file plans
800
+ #: board.yaml content with no SDK checkout to consult (it is SDK-free by
801
+ #: design -- see `test_init_command.py`'s "`tan init` is SDK-free and
802
+ #: cannot tell a ..." precedent), so this needs only the family CODE
803
+ #: encoded in the SKU string itself, never a metadata lookup.
804
+ _SKU_FAMILY_PREFIX = re.compile(r"^E1M-(AEN|V2N|V2M)")
805
+
806
+
807
+ def _same_som_family(a: str, b: str) -> bool:
808
+ """Whether two SKUs share the SoM family a `hw_rev:` value is scoped to.
809
+
810
+ tan-cli#1008 review round 4 minor: an INTRA-family retarget (e.g.
811
+ `E1M-AEN801` -> `E1M-AEN301`, both `aen`) shares ONE family
812
+ `hw-revisions.yaml` table, so an explicit `hw_rev:` valid for the source
813
+ SKU is still a real, declared revision for the target one -- dropping it
814
+ there (as an unconditional cross-SKU drop would) silently substitutes
815
+ the new SKU's own `default_hw_rev:`, which can be a DIFFERENT declared
816
+ revision with different `pad_route_overrides` -- a silent change of
817
+ which hardware variant gets built, with `tan validate` clean and `tan
818
+ init` reporting no issue, since both revisions are legitimately known
819
+ and buildable. That is strictly worse than tan-cli#743's original bug: a
820
+ loud refusal became a silent substitution. A CROSS-family retarget (the
821
+ tan-cli#743/#1008 round-3 case: `E1M-AEN801` -> `E1M-V2N101`) still
822
+ drops it -- the value is from a table that has nothing to do with the
823
+ new SKU at all, not merely a different declared revision of the same
824
+ hardware family. Returns `False` (the conservative, already-shipped
825
+ round-3 behaviour: drop) whenever either SKU does not match the known
826
+ family pattern -- a shape this function cannot judge safely.
827
+ """
828
+ match_a = _SKU_FAMILY_PREFIX.match(a)
829
+ match_b = _SKU_FAMILY_PREFIX.match(b)
830
+ return match_a is not None and match_b is not None and match_a.group(1) == match_b.group(1)
831
+
832
+
833
+ def retarget_board_yaml_som(content: str, sku: str) -> str:
834
+ """Rewrite the FIRST `som:` -> `sku:` value to `sku`, leaving the rest of
835
+ that line byte-for-byte alone -- UNLESS the value is actually changing, in
836
+ which case a trailing comment on that line is dropped (all of it, however
837
+ many physical lines it spans) and a sibling `hw_rev:` line inside the same
838
+ `som:` block, if one is present, is dropped outright.
839
+
840
+ `wizard::retarget_board_yaml_som`. Only the value token moves: the gap
841
+ before a trailing comment is preserved, so a column-aligned inline comment
842
+ (the vendored `iot` scaffold's `sku:` line has one) survives, and passing a
843
+ tree its OWN vendored SKU is a byte-exact no-op (`--template
844
+ iot-starter` always does: its `--som` is validated equal to
845
+ `TEMPLATE_SUPPORTED_SKUS` before this ever runs). Reconstructing the tail
846
+ as a fixed two-space gap silently collapsed that alignment even in the
847
+ no-op case.
848
+
849
+ A DIFFERENT `sku` is the one case that tail must NOT survive: an
850
+ `--from-example` board.yaml's inline comment routinely names the ORIGINAL
851
+ SoM's vendor/silicon (e.g. `sku: E1M-AEN801 # Alif Ensemble E8 SoM`), and
852
+ there is no SKU->vendor-name table here to rewrite it correctly for the new
853
+ one -- retargeting onto `E1M-V2N101` left that Alif comment on a Renesas
854
+ SKU. Dropping it is honest; inventing a new one is not this function's job.
855
+
856
+ tan-cli#404 -- that drop used to be strictly line-local, and 12 of the 100
857
+ `board.yaml` files in the SDK example catalogue carry a `sku:` comment that
858
+ WRAPS. `aen/edgeai-vision-aen` retargeted onto `E1M-V2N101` kept four of its
859
+ five comment lines: a Renesas RZ/V2N project whose `board.yaml` documented a
860
+ pair of Alif Ethos-U55s, an Ethos-U85 and a VeriSilicon ISP Pico
861
+ (`vsi,isp-pico`) attached to nothing -- in a file the project ships as
862
+ teaching material, with nothing in `tan init`'s output saying a comment had
863
+ been rewritten at all. So the drop now consumes the block: the inline
864
+ comment plus every following comment-only line indented past the `sku:` key.
865
+ Deliberately anchored to THAT comment, not to any comment near a changed
866
+ `sku:` -- a `sku:` line with no comment of its own opens no block, so a
867
+ comment documenting the next key is never swallowed.
868
+
869
+ tan-cli#1008 review round 3: the identical "dropping it is honest,
870
+ inventing a new one is not this function's job" reasoning applies to an
871
+ explicit `hw_rev:` sibling. This function previously only ever rewrote
872
+ `sku:`, so a retarget onto a DIFFERENT SKU used to leave the ORIGINAL
873
+ example's `hw_rev:` in place verbatim -- a value from a different
874
+ family's table (or, worse, one that happens to collide with an unrelated
875
+ revision key in the new family's table), producing a `sku:`/`hw_rev:`
876
+ pair no family table actually declares. `tan validate` refuses that with
877
+ "not a known hardware revision", while `tan init` -- before this fix --
878
+ said nothing at all, or (an earlier round of this same fix) named the
879
+ WRONG revision: both are the tan-cli#743 contradiction this whole check
880
+ exists to close, just reached via a stale cross-retarget value instead
881
+ of an absent one. Dropping the sibling `hw_rev:` on a CROSS-family
882
+ retarget lets the scaffold fall back to the NEW SoM's own
883
+ `default_hw_rev:` -- the same resolution rule a board.yaml with no
884
+ explicit `hw_rev:` at all already follows, and the one `tan validate`
885
+ resolves against.
886
+
887
+ tan-cli#1008 review round 4 minor: an INTRA-family retarget keeps the
888
+ sibling `hw_rev:` instead -- see `_same_som_family`'s own docstring for
889
+ the full reasoning (short version: within one family the value is still
890
+ a real, deliberately-chosen revision, and dropping it there would
891
+ silently substitute a DIFFERENT declared revision -- possibly with
892
+ different `pad_route_overrides` -- with no warning at all, which is
893
+ worse than the bug this fix closes). Also round 4: `retarget_board_yaml_
894
+ som`'s own scan and `vendored_som`'s reader (used below to learn
895
+ `existing_sku`) now share ONE `som:`-block-entry predicate
896
+ (`_is_som_key_line`) -- they used to disagree on a `som:` line carrying
897
+ a trailing comment or trailing whitespace, which silently reintroduced
898
+ this same stale-`hw_rev:` defect on exactly that shape of file.
899
+
900
+ tan-cli#1029: raises [`FlowStyleSomError`] on a flow-style `som:` block
901
+ (`som: {sku: ..., hw_rev: ...}`) instead of silently returning `content`
902
+ byte-for-byte unchanged -- inherited for free from the `vendored_som`
903
+ call immediately below, which raises first.
904
+
905
+ tan-cli#1041: the same inheritance now also covers
906
+ [`UnreadableSomBlockError`] -- a `som:` block this line-oriented scan
907
+ (the one below, not `vendored_som`'s) would otherwise silently leave
908
+ untouched because it never finds a literal `sku:` line to rewrite (a
909
+ `*alias`, an un-overridden `<<:` merge key inside OR outside the `som:`
910
+ block, or a flow mapping split across more than one line). A QUOTED
911
+ `som:`/`"som":` key is NOT one of these: it retargets correctly, same
912
+ as the bare spelling, because [`top_level_key_name`] (tan-cli#1041)
913
+ unquotes it before the shared `_is_som_key_line`/`_split_child_key`
914
+ checks below ever see it -- nothing about a quoted KEY stops the scan
915
+ from finding a perfectly ordinary literal `sku:` line beneath it.
916
+ """
917
+ existing_sku, _existing_hw_rev = vendored_som(content)
918
+ changing_sku = existing_sku is not None and existing_sku != sku
919
+ drop_hw_rev = changing_sku and not _same_som_family(existing_sku, sku)
920
+
921
+ out: list[str] = []
922
+ in_som = False
923
+ sku_rewritten = False
924
+ hw_rev_dropped = not drop_hw_rev # nothing to drop for a no-op or intra-family retarget
925
+ # The indent of whichever dropped construct's wrapped-comment
926
+ # continuation lines are still being consumed; `None` at every other
927
+ # point. Shared by the `sku:` comment drop and the `hw_rev:` line drop
928
+ # below -- they never overlap, since each anchors a distinct physical
929
+ # line.
930
+ consuming: str | None = None
931
+ for line in content.split("\n"):
932
+ body, cr = _split_cr(line)
933
+ if consuming is not None:
934
+ if _is_wrapped_comment_line(body, consuming):
935
+ continue
936
+ consuming = None
937
+ trimmed = body.lstrip(" \t")
938
+ if body and body[0] not in " \t":
939
+ # A new top-level key: entering `som:`, or leaving it.
940
+ in_som = _is_som_key_line(body)
941
+ out.append(line)
942
+ continue
943
+ child = _split_child_key(trimmed) if in_som else None
944
+ child_key, child_rest = child if child is not None else (None, "")
945
+ if in_som and not hw_rev_dropped and child_key == "hw_rev":
946
+ indent = body[: len(body) - len(trimmed)]
947
+ hw_rev_dropped = True
948
+ consuming = indent # also drop any wrapped comment it opened
949
+ continue
950
+ elif in_som and not sku_rewritten and child_key == "sku":
951
+ indent = body[: len(body) - len(trimmed)]
952
+ new_body, comment_dropped = _retargeted_sku_line(indent, child_rest, sku)
953
+ out.append(new_body + cr)
954
+ sku_rewritten = True
955
+ consuming = indent if comment_dropped else None
956
+ continue
957
+ out.append(line)
958
+ return "\n".join(out)
959
+
960
+
961
+ def _indent_of(body: str) -> int:
962
+ return len(body) - len(body.lstrip(" \t"))
963
+
964
+
965
+ def _cores_block_span(lines: list[str]) -> tuple[int, int] | None:
966
+ """`(start, end)` half-open line indices of the top-level `cores:` block's
967
+ CHILDREN, or `None` when there is no such block. `start` is the line after
968
+ `cores:`; `end` is the next column-0 key (or EOF)."""
969
+ start: int | None = None
970
+ for index, line in enumerate(lines):
971
+ body, _ = _split_cr(line)
972
+ if not body or body[0] in " \t":
973
+ continue
974
+ if start is not None:
975
+ return (start, index)
976
+ if body.lstrip(" \t").startswith("cores:"):
977
+ start = index + 1
978
+ return None if start is None else (start, len(lines))
979
+
980
+
981
+ def retarget_board_yaml_cores(content: str, sku: str, source_sku: str) -> str:
982
+ """Re-derive a vendored `cores:` block for `sku` (tan-cli#494 defect 2).
983
+
984
+ `tan init` picks a vendored tree by FAMILY (`_family_bucket`) and then
985
+ retargeted only the `som: sku:` line, so every SKU outside the two
986
+ representative ones (`E1M-AEN801` / `E1M-V2N101`) inherited that tree's
987
+ core ids verbatim. `tan init --template edge-ai-starter --som E1M-AEN301`
988
+ wrote `cores: a32_cluster:` for an Ensemble E3, which has no Cortex-A32 --
989
+ reported `ok:true` / `exitCode 0` / `issues:[]`, and `tan validate` then
990
+ hard-errored (exit 2) on the very next command. An unvendored family landed
991
+ on the Alif tree and got `m55_hp`, contradicting this same module's `app_core_for_sku`.
992
+
993
+ Two edits, both of which can only REMOVE wrong facts, never invent new
994
+ ones -- `tan init` is SDK-free and has no SoM topology to consult:
995
+
996
+ * the APP core (the one entry declaring `app:`) is renamed to
997
+ `app_core_for_sku(sku)`, tan's own family mapping, the same one
998
+ `minimal-app` already renders and `tan validate` re-checks against the
999
+ real catalogue;
1000
+ * every OTHER entry is DROPPED. Those are the `os: "off"` secondary
1001
+ cluster declarations (`a32_cluster` on the E8, `a55_cluster` on the
1002
+ V2N) which exist only on the tree's own representative SKU. A
1003
+ core absent from `cores:` is simply not built, so dropping is always
1004
+ sound; keeping a made-up id, or guessing the target's cluster id from a
1005
+ table tan cannot verify, is not.
1006
+
1007
+ NO-OP by construction when `sku == source_sku` (the tree's own SKU, which
1008
+ is what `E1M-AEN801`/`E1M-V2N101` always hit) -- byte-exact passthrough,
1009
+ so the parity and vendored-capture fixtures are untouched. Also a no-op
1010
+ when the block has no unambiguous single `app:` entry: an unrecognised
1011
+ shape is left verbatim rather than half-rewritten.
1012
+ """
1013
+ if sku == source_sku:
1014
+ return content
1015
+ lines = content.split("\n")
1016
+ span = _cores_block_span(lines)
1017
+ if span is None:
1018
+ return content
1019
+ start, end = span
1020
+ entry_indent: int | None = None
1021
+ entries: list[tuple[int, int]] = [] # (key line index, entry end index)
1022
+ for index in range(start, end):
1023
+ body, _ = _split_cr(lines[index])
1024
+ if not body.strip():
1025
+ continue
1026
+ indent = _indent_of(body)
1027
+ if entry_indent is None:
1028
+ entry_indent = indent
1029
+ if indent == entry_indent:
1030
+ if entries:
1031
+ entries[-1] = (entries[-1][0], index)
1032
+ entries.append((index, end))
1033
+ if not entries:
1034
+ return content
1035
+ entries[-1] = (entries[-1][0], end)
1036
+
1037
+ def declares_app(entry: tuple[int, int]) -> bool:
1038
+ key, stop = entry
1039
+ return any(
1040
+ _split_cr(lines[i])[0].lstrip(" \t").startswith("app:")
1041
+ for i in range(key + 1, stop)
1042
+ )
1043
+
1044
+ app_entries = [e for e in entries if declares_app(e)]
1045
+ if len(app_entries) != 1:
1046
+ return content
1047
+ app_key, app_stop = app_entries[0]
1048
+ key_body, key_cr = _split_cr(lines[app_key])
1049
+ trimmed = key_body.lstrip(" \t")
1050
+ if ":" not in trimmed:
1051
+ return content
1052
+ indent = key_body[: len(key_body) - len(trimmed)]
1053
+ tail = trimmed[trimmed.index(":") :]
1054
+ kept = [f"{indent}{app_core_for_sku(sku)}{tail}{key_cr}"]
1055
+ kept.extend(lines[app_key + 1 : app_stop])
1056
+ # The block's own trailing blank line separates `cores:` from the next
1057
+ # top-level key. It rides along with whichever entry came last, so it is
1058
+ # re-appended by hand when that entry was one of the dropped ones.
1059
+ if end > start and not lines[end - 1].strip() and kept[-1].strip():
1060
+ kept.append(lines[end - 1])
1061
+ return "\n".join([*lines[:start], *kept, *lines[end:]])
1062
+
1063
+
1064
+ # ---------------------------------------------------------------------------
1065
+ # `--cores`: heterogeneous scaffolding
1066
+ # ---------------------------------------------------------------------------
1067
+
1068
+ #: Accepted per-core OS values for a `--cores` entry (`id:os`). Mirrors
1069
+ #: `tan-cli::commands::init::resolve::CORE_OS_CHOICES`.
1070
+ _CORE_OS_CHOICES = ("zephyr", "yocto", "baremetal", "off")
1071
+
1072
+
1073
+ class CoresError(Exception):
1074
+ """`--cores` failed to parse or validate; carries the user-facing message
1075
+ `init_cmd` wraps as `init.invalid-cores` (exit 2 -- validation)."""
1076
+
1077
+ def __init__(self, message: str) -> None:
1078
+ super().__init__(message)
1079
+ self.message = message
1080
+
1081
+
1082
+ def _is_valid_core_id(core_id: str) -> bool:
1083
+ """`^[a-z][a-z0-9_]+$`, hand-checked (ASCII only) to mirror the Rust
1084
+ byte-level test exactly rather than trust `str.isalpha`/`isdigit`, which
1085
+ are Unicode-aware and would accept an id `board.yaml`'s own schema (and
1086
+ the Rust CLI) both reject."""
1087
+ if len(core_id) < 2 or not ("a" <= core_id[0] <= "z"):
1088
+ return False
1089
+ return all(("a" <= c <= "z") or ("0" <= c <= "9") or c == "_" for c in core_id)
1090
+
1091
+
1092
+ def parse_cores(raw: str | None) -> list[tuple[str, str]]:
1093
+ """Parse + validate `--cores` (`id[:os],...`) into `(id, os)` pairs. OS is
1094
+ inferred from the core-id silicon class when omitted. Raises `CoresError`
1095
+ on an id outside `^[a-z][a-z0-9_]+$`, an unknown OS, or a duplicate id --
1096
+ invalid values would otherwise flow verbatim into board.yaml. `None`/empty
1097
+ -> no cores (single-core default). Mirrors
1098
+ `tan-cli::commands::init::resolve::parse_cores`."""
1099
+ if not raw:
1100
+ return []
1101
+ cores: list[tuple[str, str]] = []
1102
+ for entry in raw.split(","):
1103
+ entry = entry.strip()
1104
+ if not entry:
1105
+ continue
1106
+ id_part, _sep, os_part = entry.partition(":")
1107
+ core_id = id_part.strip()
1108
+ if not core_id:
1109
+ continue
1110
+ if not _is_valid_core_id(core_id):
1111
+ raise CoresError(
1112
+ f"Invalid core id '{core_id}' in --cores (expected lowercase id "
1113
+ f"matching ^[a-z][a-z0-9_]+$, e.g. m33_sm)."
1114
+ )
1115
+ os_value = os_part.strip()
1116
+ if os_value:
1117
+ if os_value not in _CORE_OS_CHOICES:
1118
+ raise CoresError(
1119
+ f"Invalid OS '{os_value}' for core '{core_id}' in --cores "
1120
+ f"(expected one of: zephyr, yocto, baremetal, off)."
1121
+ )
1122
+ else:
1123
+ os_value = infer_runtime_for_core_id(core_id)
1124
+ if any(existing == core_id for existing, _ in cores):
1125
+ raise CoresError(f"Duplicate core id '{core_id}' in --cores.")
1126
+ cores.append((core_id, os_value))
1127
+ return cores
1128
+
1129
+
1130
+ def _rust_lines(text: str) -> list[str]:
1131
+ """`text.split("\\n")`, minus a trailing empty element from a trailing
1132
+ `\\n` -- what Rust's `str::lines()` yields for the same LF-only text, and
1133
+ what `vendored_app_core_key`/`vendored_core_ids`/`splice_companion_cores`
1134
+ below are ported line-for-line against."""
1135
+ lines = text.split("\n")
1136
+ if lines and lines[-1] == "":
1137
+ lines.pop()
1138
+ return lines
1139
+
1140
+
1141
+ def vendored_app_core_key(board_yaml: str) -> str | None:
1142
+ """The `cores:` block's APP-core key (e.g. `"m33_sm"`) -- the core whose
1143
+ block owns an `app:` line, NOT simply the first core listed. A
1144
+ heterogeneous scaffold (`edge-ai`) lists its companion core FIRST
1145
+ (`os: "off"`, no `app:`); trusting position would return the companion.
1146
+ Works uniformly on every template this port plans (including the
1147
+ hand-generated `minimal-app`, whose sole core also carries `app:`), so
1148
+ unlike the Rust port there is no need for a per-template
1149
+ `vendored_*_app_core_for_sku` family -- this reads the ACTUAL planned
1150
+ content instead of a second, independently-derived lookup. Mirrors
1151
+ `wizard::service::vendored::vendored_app_core_key`."""
1152
+ in_cores = False
1153
+ current_core: str | None = None
1154
+ for line in _rust_lines(board_yaml):
1155
+ if not in_cores:
1156
+ in_cores = line == "cores:"
1157
+ continue
1158
+ if line and not line[0].isspace():
1159
+ break # The next top-level key ends the cores: block.
1160
+ if line.startswith(" ") and not line[2:3].isspace() and line.endswith(":"):
1161
+ current_core = line[2:-1] # A ` <core>:` entry key line.
1162
+ elif line.startswith(" app:"):
1163
+ if current_core is not None:
1164
+ return current_core
1165
+ return None
1166
+
1167
+
1168
+ def vendored_core_ids(board_yaml: str) -> list[tuple[str, str]]:
1169
+ """Every `(core id, os)` pair declared in a board.yaml's `cores:` block,
1170
+ in file order -- the app core AND any pre-declared companions (e.g.
1171
+ edge-ai's `a55_cluster`/`a32_cluster`). `os` is that core's own `os:`
1172
+ child line verbatim (quotes and all, e.g. `"off"`), or `"zephyr"` when
1173
+ absent (every vendored app core omits `os:` -- implicitly Zephyr).
1174
+ Mirrors `wizard::service::vendored::vendored_core_ids`."""
1175
+ in_cores = False
1176
+ ids: list[list[str]] = []
1177
+ for line in _rust_lines(board_yaml):
1178
+ if not in_cores:
1179
+ in_cores = line == "cores:"
1180
+ continue
1181
+ if line and not line[0].isspace():
1182
+ break
1183
+ if line.startswith(" ") and not line[2:3].isspace() and line.endswith(":"):
1184
+ ids.append([line[2:-1], "zephyr"])
1185
+ elif line.startswith(" os: "):
1186
+ if ids:
1187
+ ids[-1][1] = line[len(" os: ") :]
1188
+ return [(core_id, os_value) for core_id, os_value in ids]
1189
+
1190
+
1191
+ def _yaml_scalar_value(after_colon: str) -> str | None:
1192
+ """The bare scalar value from the text AFTER a mapping key's `:` --
1193
+ stripped of leading whitespace, a trailing comment, and any surrounding
1194
+ quote characters (`'`/`"`) -- mirroring
1195
+ `generate_cmd._scan_som_sku`/`bootstrap_cmd._scan_board_slice`'s own
1196
+ `.strip().strip("'\"")` rule for this identical scalar.
1197
+
1198
+ tan-cli#1008 review round 5: this function used to keep the quotes,
1199
+ which silently evaded the checks reading its result. A quoted
1200
+ `sku: "E1M-AEN801"` made `_SKU_FAMILY.match('"E1M-AEN801"')` fail, so the
1201
+ family check saw nothing to judge, reachable through an unstripped
1202
+ quote. A quoted `hw_rev: "r1"` was worse: `changing_sku` (a bare string compare)
1203
+ read `'"E1M-AEN801"' != "E1M-AEN801"` as TRUE even for a byte-for-byte
1204
+ intra-SKU no-op, so both round three's no-op guard and round four's
1205
+ intra-family guard were defeated at once -- a real `hw_rev:` silently
1206
+ dropped and replaced by the SoM's own `default_hw_rev:`, possibly a
1207
+ DIFFERENT declared revision with different `pad_route_overrides`, with
1208
+ `tan validate` clean and `tan init` reporting no issue.
1209
+
1210
+ `None` for nothing after the colon, or only a comment.
1211
+ """
1212
+ stripped = after_colon.lstrip(" \t")
1213
+ if not stripped or stripped.startswith("#"):
1214
+ return None
1215
+ match = re.search(r"[ \t]", stripped)
1216
+ token = stripped[: match.start()] if match else stripped
1217
+ return token.strip("'\"") or None
1218
+
1219
+
1220
+ def vendored_som(board_yaml: str) -> tuple[str | None, str | None]:
1221
+ """The `som:` block's `sku:`/`hw_rev:` scalar values, read the same
1222
+ line-oriented way `vendored_app_core_key`/`vendored_core_ids` read the
1223
+ `cores:` block -- `None` for either key that is absent or carries no
1224
+ value. Tolerates a space before the child key's colon (`sku : x`,
1225
+ round 5, via the shared [`_split_child_key`] -- round 6: this reader and
1226
+ `retarget_board_yaml_som`'s writer now both call it, rather than each
1227
+ keeping its own copy of the rule) and strips surrounding quote
1228
+ characters from the value (round 5, `_yaml_scalar_value`).
1229
+
1230
+ tan-cli#743 majors 1+2: this is what reads the SoM/hw_rev pair a scaffolded board.yaml ACTUALLY
1231
+ carries, rather than `--som` (silently absent on a bare
1232
+ `--from-example`/`--topology`, even though the copied board.yaml already
1233
+ names a SKU on disk) or an assumed-absent `hw_rev:` (`retarget_board_yaml_som`
1234
+ drops a sibling `hw_rev:` only on a CROSS-family retarget -- tan-cli#1008
1235
+ review rounds 3+4 -- and otherwise leaves it exactly as the source
1236
+ example wrote it, whether that is the ORIGINAL SKU's own value or one
1237
+ surviving a same-family retarget, so it must be read, not presumed
1238
+ absent).
1239
+
1240
+ Raises [`FlowStyleSomError`] (tan-cli#1029) the moment the top-level
1241
+ `som:` line turns out to be flow-style rather than block-style --
1242
+ `retarget_board_yaml_som` calls this function first and so inherits the
1243
+ identical refusal, rather than each guessing independently whether a
1244
+ line it cannot read is safe to treat as "no som: block at all".
1245
+
1246
+ Also raises [`UnreadableSomBlockError`] (tan-cli#1041) when a `som:`
1247
+ entry is present -- literally, or (see below) only once the document is
1248
+ actually parsed -- but no LITERAL `sku:` line was found nested beneath
1249
+ it: see that class's own docstring for the four spellings this covers.
1250
+ Two different signals feed it, because only one of them can see a
1251
+ `som:` this scan never finds text for at all:
1252
+
1253
+ - `entered_som`: this scan's OWN `_is_som_key_line` matched a literal
1254
+ `som:`/`"som":` line, so a real block was entered, but the loop below
1255
+ never set `sku`. Covers an alias and an in-block merge key with no
1256
+ override, and -- since `FlowStyleSomError`'s own detector only ever
1257
+ looks at the `som:` line itself -- a flow mapping split across more
1258
+ than one physical line, which this loop's per-line `_split_child_key`
1259
+ also never recognises as a `sku:` child (its first `:` splits on the
1260
+ wrong token, e.g. `{sku` from ` {sku: ..., hw_rev: ...}`).
1261
+ - the `yaml.safe_load` backstop below, run ONLY when `entered_som` is
1262
+ still `False` at the end of the scan (i.e. this scan is about to
1263
+ report "no som: block at all"): a document-root merge key (`<<:
1264
+ *base`, a SIBLING of `som:`, not nested under it) can produce an
1265
+ effective `som:` mapping with no `som:` TEXT anywhere in the file for
1266
+ any line scan to ever find. Deferred, function-local `import yaml` --
1267
+ `tests/gates/test_cli_import_is_lean.py` (tan-cli#810) pins that a
1268
+ bare `tan --version` loads no YAML machinery at all, and this module
1269
+ is reached from `init_cmd`, which `tan/cli.py` static-imports on
1270
+ every invocation -- so the import must stay inside the one branch
1271
+ that is actually reached only from a real `tan init`/`generate` call,
1272
+ never at import time. Swallows a genuine YAML syntax error the same
1273
+ way the rest of this function already tolerates one (as "nothing
1274
+ found"): a document this broken was never going to scan cleanly
1275
+ either, and this backstop's whole job is narrower than "validate the
1276
+ file" -- it only asks "did a merge manufacture a `som:` this scan is
1277
+ blind to".
1278
+
1279
+ tan-cli#1041 asked, verbatim, that reading the `som:` block with a real
1280
+ YAML parse be considered, and that a rejection say why in the code. Here
1281
+ is why: a real parse would tell `retarget_board_yaml_som` (the writer
1282
+ that calls this reader first) THAT a `sku:` exists and what its parsed
1283
+ value is, but not WHICH BYTES on WHICH LINE to rewrite -- a `yaml.Node`
1284
+ carries no reliable back-reference to source position/formatting, and
1285
+ `retarget_board_yaml_som` must reproduce everything this scan does not
1286
+ touch byte-for-byte: a hand-authored comment, a wrapped comment block
1287
+ (`_is_wrapped_comment_line`), inline alignment, the file's own CRLF-vs-LF
1288
+ terminator (`_split_cr`, tan-cli#404). A parse-then-reserialize writer
1289
+ would lose all of that -- exactly the class of regression the module
1290
+ docstring's own "Deliberately NOT reimplemented here" paragraph already
1291
+ refuses for the CMakeLists rewrite, for the identical reason. So the
1292
+ line-oriented scan stays the reader for every shape it CAN see text for,
1293
+ and `yaml.safe_load` is used only as a narrow, read-only DETECTOR for
1294
+ the one shape it cannot -- a document-root merge key -- never as a
1295
+ second source of the `sku`/`hw_rev` values this function returns; those
1296
+ two are always `_yaml_scalar_value` of a literal line, so a value the
1297
+ scan found and one a full parse would find never have a chance to
1298
+ disagree.
1299
+ """
1300
+ in_som = False
1301
+ entered_som = False
1302
+ found_sku_key = False
1303
+ sku: str | None = None
1304
+ hw_rev: str | None = None
1305
+ for line in _rust_lines(board_yaml):
1306
+ body, _cr = _split_cr(line)
1307
+ if not in_som:
1308
+ flow_body = _som_flow_style_body(body)
1309
+ if flow_body is not None:
1310
+ raise FlowStyleSomError(flow_body)
1311
+ in_som = _is_som_key_line(body)
1312
+ entered_som = entered_som or in_som
1313
+ continue
1314
+ if body and not body[0].isspace():
1315
+ break # The next top-level key ends the som: block.
1316
+ trimmed = body.lstrip(" \t")
1317
+ child = _split_child_key(trimmed)
1318
+ if child is None:
1319
+ continue
1320
+ child_key, child_rest = child
1321
+ if child_key == "sku":
1322
+ found_sku_key = True
1323
+ sku = _yaml_scalar_value(child_rest)
1324
+ elif child_key == "hw_rev":
1325
+ hw_rev = _yaml_scalar_value(child_rest)
1326
+ # `found_sku_key`, not `sku is None` -- a bare `sku:`/`sku: # tbd` (no
1327
+ # value token at all) is a LITERAL `sku:` line this scan found and
1328
+ # `retarget_board_yaml_som` already knows how to fill in
1329
+ # (`_retargeted_sku_line`'s own "nothing after the colon" branch); it
1330
+ # must not be confused with the six tan-cli#1041 shapes where no `sku:`
1331
+ # line exists for the scan to find in the first place.
1332
+ if entered_som and not found_sku_key:
1333
+ raise UnreadableSomBlockError()
1334
+ if not entered_som:
1335
+ import yaml # noqa: PLC0415 (declared base dep; deferred -- see docstring)
1336
+
1337
+ try:
1338
+ doc = yaml.safe_load(board_yaml)
1339
+ except yaml.YAMLError:
1340
+ doc = None
1341
+ if isinstance(doc, dict) and isinstance(doc.get("som"), dict):
1342
+ raise UnreadableSomBlockError()
1343
+ return sku, hw_rev
1344
+
1345
+
1346
+ def splice_companion_cores(board_yaml: str, cores: list[tuple[str, str]]) -> str:
1347
+ """Splice `--cores` companions (and a default RPMsg channel to the first
1348
+ ACTIVE one, `os != "off"`) into `board_yaml`, after the sole app-core
1349
+ entry already inside its `cores:` block. A no-op when `cores` is empty or
1350
+ the content has no `cores:` block. Skips any id already declared in the
1351
+ `cores:` block (not just the app core) so this can never emit a
1352
+ duplicate mapping key -- belt-and-suspenders behind `init_cmd`'s own
1353
+ upfront guard. Mirrors
1354
+ `wizard::service::vendored::splice_companion_cores`."""
1355
+ if not cores:
1356
+ return board_yaml
1357
+ app_core = vendored_app_core_key(board_yaml)
1358
+ if app_core is None:
1359
+ return board_yaml
1360
+ existing_ids = {core_id for core_id, _os in vendored_core_ids(board_yaml)}
1361
+
1362
+ companion_lines: list[str] = []
1363
+ for core_id, os_value in cores:
1364
+ if core_id == app_core or core_id in existing_ids:
1365
+ continue
1366
+ companion_lines.append(f" {core_id}:")
1367
+ # `off` is a YAML 1.1 boolean keyword (`yaml.safe_load("os: off")` ->
1368
+ # `{"os": False}`), so an unquoted companion entry writes a bool where
1369
+ # the schema requires the string `"off"` -- confirmed against
1370
+ # `metadata/schemas/board-v2.schema.json`'s `os` enum, which rejects
1371
+ # `False` with "False is not of type 'string'". Every vendored
1372
+ # scaffold already quotes it this way (e.g.
1373
+ # `tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml`); the other
1374
+ # three enum values (`zephyr`/`yocto`/`baremetal`) are not YAML
1375
+ # keywords and stay bare to match that same convention.
1376
+ os_literal = '"off"' if os_value == "off" else os_value
1377
+ companion_lines.append(f" os: {os_literal}")
1378
+ if os_value == "yocto":
1379
+ companion_lines.append(" image: alp-image-edge")
1380
+ if not companion_lines:
1381
+ return board_yaml
1382
+
1383
+ out: list[str] = []
1384
+ in_cores = False
1385
+ inserted = False
1386
+ for line in _rust_lines(board_yaml):
1387
+ is_top_level = bool(line) and line[0] not in (" ", "\t")
1388
+ if is_top_level:
1389
+ if in_cores and not inserted:
1390
+ out.extend(companion_lines)
1391
+ inserted = True
1392
+ in_cores = line == "cores:"
1393
+ out.append(line)
1394
+ if in_cores and not inserted:
1395
+ out.extend(companion_lines)
1396
+ inserted = True
1397
+
1398
+ result = "\n".join(out) + "\n"
1399
+ if inserted:
1400
+ companion = next(
1401
+ (
1402
+ core_id
1403
+ for core_id, os_value in cores
1404
+ if core_id != app_core and os_value != "off" and core_id not in existing_ids
1405
+ ),
1406
+ None,
1407
+ )
1408
+ # tan-cli#925: never append over a board that already declares
1409
+ # `ipc:`. PyYAML accepts a duplicate top-level key and keeps the
1410
+ # LAST, so an unconditional append does not fail -- it silently
1411
+ # discards the project's own channel. alp-sdk's multicore-mailbox
1412
+ # scaffold declares `alp_shmem0`, and both its `src/main.c` and
1413
+ # `peer/main.c` `#define SHMEM_REGION_NAME "alp_shmem0"`.
1414
+ declares_ipc = any(line.startswith("ipc:") for line in _rust_lines(board_yaml))
1415
+ if companion is not None and not declares_ipc:
1416
+ result += (
1417
+ "\nipc:\n"
1418
+ " - kind: rpmsg\n"
1419
+ " name: alp_default_rpmsg\n"
1420
+ f" endpoints: [{app_core}, {companion}]\n"
1421
+ " carve_out_kb: 256\n"
1422
+ )
1423
+ return result
1424
+
1425
+
1426
+ # ---------------------------------------------------------------------------
1427
+ # Template planning
1428
+ # ---------------------------------------------------------------------------
1429
+
1430
+
1431
+ def plan_template_files(template_id: str, sku: str) -> list[PlannedFile]:
1432
+ """The files `template_id` lays down for `sku`.
1433
+
1434
+ Raises `TemplateDataError` when a vendored tree cannot be read,
1435
+ `UnsupportedSomError` when tan vendors no tree for `sku`'s SoM family
1436
+ (tan-cli#579), and is `KeyError`-free otherwise: the caller has already
1437
+ validated `template_id` against `TEMPLATE_IDS`.
1438
+
1439
+ `minimal-app` can never raise the second: it is hand-generated here and
1440
+ vendor-neutral, so it scaffolds every SKU -- see `UnsupportedSomError`.
1441
+ """
1442
+ if template_id == "minimal-app":
1443
+ return _minimal_app_files(sku)
1444
+ return _vendored_files(_VENDORED_TEMPLATE_DIR[template_id], template_id, sku)
1445
+
1446
+
1447
+ def _vendored_family(template_id: str, sku: str) -> str:
1448
+ """Which family subdirectory `template_id` reads for `sku` -- and the one
1449
+ place that refuses a SoM family tan vendors no tree for (tan-cli#579).
1450
+
1451
+ Split out of `_vendored_files` (which does the IO) so the family choice --
1452
+ the decision tan-cli#579 got wrong -- is one small pure lookup with its
1453
+ refusal beside it. Raising HERE means the caller's run fails before a
1454
+ single byte of another vendor's tree is read, so no half-planned project
1455
+ can escape.
1456
+ """
1457
+ # `iot` has exactly one vendored tree, no family split (its caller rejects
1458
+ # any other SKU first); every other template has two.
1459
+ restricted = TEMPLATE_SUPPORTED_SKUS.get(template_id)
1460
+ family = restricted[0] if restricted else _family_bucket(sku)
1461
+ if family is None:
1462
+ raise UnsupportedSomError(template_id, sku)
1463
+ return family
1464
+
1465
+
1466
+ def _vendored_files(tree: str, template_id: str, sku: str) -> list[PlannedFile]:
1467
+ """Read a vendored scaffold tree and retarget its `board.yaml` onto `sku`.
1468
+
1469
+ Files come back sorted by their relative POSIX path (`CMakeLists.txt`,
1470
+ `README.md`, `board.yaml`, ... -- uppercase first), the order the Rust
1471
+ `vendored_tree!` macro lists them in, so `data.fileChanges[]` matches the
1472
+ shipped binary's without a hand-kept list here to drift out of step with
1473
+ it. `iot` is the one tree where the two LISTS differ, not just their order:
1474
+ the frozen Rust tree never got `src/cc3501e_bridge.{c,h}` (tan-cli#1275,
1475
+ alp-sdk#2112 -- vendored as `NON_ENVELOPE_EXTRAS` in
1476
+ `tests/parity/scaffold_byte_parity.py` because alp-sdk's own scaffold
1477
+ catalog omits them from the envelope, alplabai/alp-sdk#2241;
1478
+ `test_scaffold_content_oracle_parity.py`'s `FILE_SET_DIVERGENCE` records
1479
+ the count divergence until tan-cli#269 deleted the oracle axis;
1480
+ `tests/core/test_template_integrity.py` pins the FILE SET now, not the
1481
+ diff), so `tan init --template iot-starter --format json` returns two
1482
+ `fileChanges[]` entries more than the oracle did. (`native_sim.conf`,
1483
+ this tree's earlier and now-retired divergence source, was removed
1484
+ entirely at tan-cli#1275 -- alp-sdk#2173 fixed the mbedtls PSA-crypto
1485
+ break it existed to work around, so `mqtt-telemetry` no longer ships
1486
+ one.) Sorting is what keeps every file the two trees DO share in the
1487
+ same relative order. Sorted on
1488
+ that STRING, never on the `Path`: `PurePath.__lt__` compares a
1489
+ case-FOLDED key on Windows, so sorting paths ordered `board.yaml` before
1490
+ `CMakeLists.txt` there and after it on Linux -- the same command emitting a
1491
+ different `fileChanges[]` order per platform.
1492
+ """
1493
+ family = _vendored_family(template_id, sku)
1494
+ root = VENDORED_ROOT / tree / family
1495
+ try:
1496
+ paths = _example_source_files(root) if root.is_dir() else []
1497
+ if not paths:
1498
+ raise TemplateDataError(
1499
+ f"tan's vendored template tree for '{template_id}' is empty at "
1500
+ f"'{root}'. This is a broken tan installation, not a project problem "
1501
+ f"-- reinstall tan, or rebuild the binary with the template data "
1502
+ f"(scripts/build_binary.sh)."
1503
+ )
1504
+ files = []
1505
+ for path in paths:
1506
+ relative = path.relative_to(root).as_posix()
1507
+ content = _read_verbatim(path)
1508
+ if relative == "board.yaml":
1509
+ content = retarget_board_yaml_som(content, sku) # tan-cli#494 defect 2
1510
+ content = retarget_board_yaml_cores(content, sku, family)
1511
+ content = retarget_selftest_som_identity(content, sku, family)
1512
+ content = retarget_selftest_soc_identity(content, sku, family)
1513
+ content = retarget_example_build_target_comment(content, sku, family)
1514
+ files.append(PlannedFile(relative, content))
1515
+ _require_complete_tree(template_id, root, files)
1516
+ except OSError as err:
1517
+ raise TemplateDataError(
1518
+ f"tan's vendored template tree for '{template_id}' could not be read at "
1519
+ f"'{root}': {err}"
1520
+ ) from err
1521
+ return files
1522
+
1523
+
1524
+ #: Files every vendored scaffold tree must carry, whatever the template: the
1525
+ #: build script, the SoM declaration, and the Zephyr config. A tree missing any
1526
+ #: of them is not a scaffold.
1527
+ _VENDORED_BASELINE_FILES = ("CMakeLists.txt", "board.yaml", "prj.conf")
1528
+
1529
+ #: `target_sources(app PRIVATE <path> ...)`, across line breaks -- `edge-ai`
1530
+ #: spells it over three lines, every other template on one.
1531
+ _TARGET_SOURCES_RE = re.compile(r"target_sources\s*\(\s*app\s+PRIVATE\s+(.*?)\)", re.DOTALL)
1532
+
1533
+
1534
+ def _require_complete_tree(template_id: str, root: Path, files: list[PlannedFile]) -> None:
1535
+ """Refuse a PARTIALLY delivered vendored tree (tan-cli#494 defect 3).
1536
+
1537
+ The `if not paths:` guard above is all-or-nothing: it fires only when the
1538
+ tree yields ZERO files, so any non-empty SUBSET read as complete. With
1539
+ `src/main.c` absent -- a mis-scoped PyInstaller `--add-data`, a partial
1540
+ copy, an extracted onedir whose `src/` lost `+x` -- `tan init` wrote five
1541
+ files, reported `ok:true` / `issues:[]`, and the `CMakeLists.txt` it DID
1542
+ write still said `target_sources(app PRIVATE src/main.c)`. The customer's
1543
+ first `tan build` then died inside CMake with "Cannot find source file",
1544
+ reported to them as their project's problem. `TemplateDataError` /
1545
+ `init.template-unreadable` is exactly the coded outcome that broken-
1546
+ installation population is owed.
1547
+
1548
+ The expected set is DERIVED, never a hand-kept list that would drift out
1549
+ of step with the trees: a baseline every scaffold has by definition
1550
+ (`_VENDORED_BASELINE_FILES`) plus every relative path the tree's own
1551
+ `CMakeLists.txt` hands to `target_sources(app PRIVATE ...)`. That second
1552
+ half is what makes the check track a template that gains a `.c` file with
1553
+ no edit here -- and it is the precise set whose absence breaks the build.
1554
+ """
1555
+ present = {f.relative_path for f in files}
1556
+ expected = set(_VENDORED_BASELINE_FILES)
1557
+ cmake = next((f.content for f in files if f.relative_path == "CMakeLists.txt"), None)
1558
+ if cmake is not None:
1559
+ for block in _TARGET_SOURCES_RE.findall(cmake):
1560
+ expected.update(tok for tok in block.split() if not tok.startswith("$"))
1561
+ missing = sorted(expected - present)
1562
+ if missing:
1563
+ raise TemplateDataError(
1564
+ f"tan's vendored template tree for '{template_id}' at '{root}' is "
1565
+ f"incomplete -- missing {', '.join(missing)}. This is a broken tan "
1566
+ f"installation, not a project problem -- reinstall tan, or rebuild the "
1567
+ f"binary with the template data (scripts/build_binary.sh)."
1568
+ )
1569
+
1570
+
1571
+ def vendored_library_names_for(template_id: str) -> list[str] | None:
1572
+ """The library names `tan explain --template <id>` reports for `template_id`.
1573
+
1574
+ `None` for `minimal-app` -- the one template with no vendored tree, whose
1575
+ caller keeps reading the registry's own (empty) `libs` field. A list
1576
+ otherwise, read straight from that template's Alif-Ensemble-family
1577
+ `board.yaml`: `Some(vec![])` in Rust terms, so an EMPTY list means "this
1578
+ scaffold genuinely ships no libraries", never "unknown".
1579
+
1580
+ Mirrors `crates/tan-core/src/wizard/service/vendored.rs`'s
1581
+ `vendored_library_names_for`, including its choice of family tree: the
1582
+ `libraries:` block is identical across the AEN/V2N pair of every
1583
+ family-split template (Rust asserts that in
1584
+ `vendored_library_names_matches_across_families`) and `tan explain` has no
1585
+ `--som` to pick one with, so the AEN tree stands in for both.
1586
+
1587
+ Fixes tan-cli#124 by construction: the summary derives from the scaffold
1588
+ bytes `tan init` actually writes, not from a second hand-synced registry
1589
+ field that went stale (`edge-ai-starter` reported no libraries while its
1590
+ vendored board.yaml declares `tflite-micro`).
1591
+
1592
+ Raises `TemplateDataError` when the tree will not read, so `tan explain`
1593
+ can report a coded issue instead of letting an `OSError` escape as a
1594
+ traceback -- the Rust `expect()`s here and panics.
1595
+ """
1596
+ tree = _VENDORED_TEMPLATE_DIR.get(template_id)
1597
+ if tree is None:
1598
+ return None
1599
+ path = VENDORED_ROOT / tree / _FAMILY_TREES[0] / "board.yaml"
1600
+ try:
1601
+ text = _read_verbatim(path)
1602
+ except OSError as err:
1603
+ raise TemplateDataError(
1604
+ f"tan's vendored board.yaml for '{template_id}' could not be read at "
1605
+ f"'{path}': {err}. This is a broken tan installation, not a project "
1606
+ f"problem -- reinstall tan, or rebuild the binary with the template "
1607
+ f"data (scripts/build_binary.sh)."
1608
+ ) from err
1609
+ return _library_names(text)
1610
+
1611
+
1612
+ def _library_names(board_yaml: str) -> list[str]:
1613
+ """Every name in a board.yaml's top-level `libraries:` block, in file order.
1614
+
1615
+ A line scan, where Rust parses the whole document through `BoardModel`. The
1616
+ input is not a customer's file -- it is tan's own vendored capture, LF, with
1617
+ a shape `tests/core/test_scaffold.py` byte-diffs against the Rust trees --
1618
+ so a YAML parser would only buy generality this never needs, and PyYAML is
1619
+ an OPTIONAL runtime import in this package (see `validate_cmd`): depending
1620
+ on it here would make `tan explain --template edge-ai-starter` answer
1621
+ differently depending on whether a wheel happened to be installed.
1622
+
1623
+ Both `LibraryEntry` spellings Rust's model accepts are covered, not just the
1624
+ scoped one every vendored tree uses today: the bare shorthand
1625
+ (`- tflite-micro`) and the scoped object (`- name: tflite-micro`, whose
1626
+ sibling keys like `cores: [m55_hp]` are skipped as they are not list items).
1627
+ Comments and blank lines inside the block are skipped; the first column-0
1628
+ line ends it, exactly as Rust's own `vendored_core_ids` line scan does.
1629
+ """
1630
+ names: list[str] = []
1631
+ in_block = False
1632
+ for line in board_yaml.splitlines():
1633
+ if not in_block:
1634
+ in_block = line.rstrip() == "libraries:"
1635
+ continue
1636
+ if line and not line[0].isspace():
1637
+ break # The next top-level key ends the libraries: block.
1638
+ entry = line.strip()
1639
+ if not entry.startswith("- "):
1640
+ continue # Blank line, comment, or a scoped entry's sibling key.
1641
+ entry = entry[2:].strip()
1642
+ key, sep, value = entry.partition(":")
1643
+ raw = value.strip() if sep and key.strip() == "name" else entry
1644
+ names.append(raw.strip("\"'"))
1645
+ return names
1646
+
1647
+
1648
+ # ---------------------------------------------------------------------------
1649
+ # `minimal-app`: the one hand-generated template
1650
+ # ---------------------------------------------------------------------------
1651
+ #
1652
+ # tan's OWN content, not a copy of anything the SDK ships -- the SDK catalog has
1653
+ # no `minimal-app` entry (its `minimal` entry is what `zephyr-app` vendors). Its
1654
+ # `board.yaml` (below) declares `os: zephyr` and its README says so too -- it
1655
+ # was always meant to build as a real Zephyr app; "hand-generated" describes
1656
+ # where the content comes from (tan's own generator, not a vendored SDK
1657
+ # capture), not a licence to skip Zephyr's own boilerplate.
1658
+ #
1659
+ # tan-cli#309 -- two bugs, not one, and fixing only the first makes the second
1660
+ # worse (a CMake configure error instead of a silent host binary):
1661
+ #
1662
+ # 1. `board.yaml`'s `app:` decides which CMakeLists.txt `west build` actually
1663
+ # configures, via the planner's `_zephyr_app_dir`
1664
+ # (`tan/planner/orchestrator.py`): it resolves `app:` to a directory, and
1665
+ # picks that directory ITSELF whenever it holds a `CMakeLists.txt` of its
1666
+ # own, falling back to the PARENT only when it does not. This template's
1667
+ # `src/` deliberately keeps its own `CMakeLists.txt` (the two-file split
1668
+ # below), so `app: ./src` (through v0.5.0-rc3) sent `west build` straight at
1669
+ # `src/CMakeLists.txt` -- the root `CMakeLists.txt` (`project()` +
1670
+ # `add_subdirectory(src)`, never `add_executable`) was dead code the whole
1671
+ # time, not the file at fault.
1672
+ # 2. `src/CMakeLists.txt` -- the file actually configured -- called plain
1673
+ # `add_executable(alp_app ${ALP_APP_SOURCES})`, no `find_package(Zephyr
1674
+ # ...)` anywhere in either file. CMake configures and links that shape fine
1675
+ # (measured: a real `alp_app.exe`, PE32+ x86-64, built from `CMakeFiles/
1676
+ # alp_app.dir/{main,features/app_bootstrap}.obj` -- app_bootstrap.c WAS
1677
+ # compiled and linked, just into a host binary Zephyr's build never ran at
1678
+ # all), so `tan build` reported success for a project that was never Zephyr.
1679
+ #
1680
+ # `_minimal_app_root_cmake`/`_minimal_app_src_cmake` below fix (2): the root
1681
+ # file now carries `find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})`
1682
+ # before `project()`, and `src/CMakeLists.txt` contributes to Zephyr's own
1683
+ # `app` target via `target_sources(app ...)` instead of a second
1684
+ # `add_executable` -- the same KIND of CMake every vendored template already
1685
+ # writes (e.g. `templates/vendored/minimal/*/CMakeLists.txt`), while keeping
1686
+ # its own hand-generated CONTENT. `_minimal_app_board_yaml` fixes (1): `app: .`
1687
+ # (the project root) so `_zephyr_app_dir` resolves to the root file directly,
1688
+ # without ever consulting `src/`. Measured after both fixes: a real CMake +
1689
+ # Ninja + Zephyr-SDK configure+build compiles `src/features/app_bootstrap.c`
1690
+ # into `app/libapp.a` alongside `src/main.c`, and Zephyr's own link step pulls
1691
+ # `libapp.a` in whole (`-Wl,--whole-archive app/libapp.a`) on the way to a real
1692
+ # `zephyr.elf`.
1693
+ #
1694
+ # Ported from `wizard/service/c_project.rs`/`gen_board_yaml`'s `app: ./src` up
1695
+ # through both defects (`crates/` is FROZEN -- see `docs/ROADMAP.md`'s standing
1696
+ # rule -- so tan-cli#309 is fixed here only, not there); `minimal-app` still
1697
+ # is not the non-interactive default (see `DEFAULT_TEMPLATE_ID`), a separate,
1698
+ # independent choice. `contract/envelopes/init-preview-minimal-app` pins its
1699
+ # exact eight-file list/order (path + change-kind only, never file content or
1700
+ # `board.yaml`'s `app:` value), which neither fix touches.
1701
+
1702
+ #: minimal-app's one feature file: `(path, unit name, TODO line)`.
1703
+ _MINIMAL_APP_FEATURE_FILE = (
1704
+ "src/features/app_bootstrap.c",
1705
+ "app_bootstrap",
1706
+ "TODO: register app services and initialize runtime modules.",
1707
+ )
1708
+
1709
+ _MINIMAL_APP_EXPLANATION = (
1710
+ "Minimal template keeps generated code intentionally small and neutral.",
1711
+ "Use this baseline when you want full control over feature bring-up order.",
1712
+ )
1713
+
1714
+ _MINIMAL_APP_BODY_LINES = (
1715
+ "Alp minimal starter boot",
1716
+ "TODO: add your application logic",
1717
+ )
1718
+
1719
+
1720
+ def _minimal_app_files(sku: str) -> list[PlannedFile]:
1721
+ feature_path, unit_name, todo_line = _MINIMAL_APP_FEATURE_FILE
1722
+ return [
1723
+ PlannedFile("board.yaml", _minimal_app_board_yaml(sku)),
1724
+ PlannedFile("README.md", _minimal_app_readme(sku)),
1725
+ # No `prj_conf_extras`: minimal-app declares none.
1726
+ PlannedFile("prj.conf", "CONFIG_ASSERT=y\nCONFIG_NEWLIB_LIBC=y\n"),
1727
+ PlannedFile("CMakeLists.txt", _minimal_app_root_cmake()),
1728
+ PlannedFile("src/CMakeLists.txt", _minimal_app_src_cmake()),
1729
+ PlannedFile(
1730
+ "include/app/app.h",
1731
+ "// SPDX-License-Identifier: Apache-2.0\n"
1732
+ "\n"
1733
+ "#ifndef ALP_APP_APP_H\n"
1734
+ "#define ALP_APP_APP_H\n"
1735
+ "\n"
1736
+ "int alp_app_init(void);\n"
1737
+ "int alp_app_run(void);\n"
1738
+ "\n"
1739
+ "#endif /* ALP_APP_APP_H */\n",
1740
+ ),
1741
+ PlannedFile("src/main.c", _minimal_app_main_c()),
1742
+ PlannedFile(feature_path, _feature_file(unit_name, todo_line)),
1743
+ ]
1744
+
1745
+
1746
+ def _minimal_app_board_yaml(sku: str) -> str:
1747
+ """A board.yaml conforming to the SDK board schema: `som` + `cores` are the
1748
+ only required top-level keys, and the OS is per-core. There is deliberately
1749
+ NO top-level `os:` key (I-02) and no way to ask for one -- a core's runtime
1750
+ follows its Cortex class, and this scaffold's app source is Zephyr.
1751
+
1752
+ `app: .` (the PROJECT ROOT), not `./src` (tan-cli#309 round 2):
1753
+ `_zephyr_app_dir` (`tan/planner/orchestrator.py`) resolves `app:` to the
1754
+ directory holding the CMakeLists.txt `west build` actually configures, and
1755
+ picks the `app:` path ITSELF whenever that path has its own
1756
+ `CMakeLists.txt` -- falling back to its parent only when it does not. This
1757
+ template's `src/` deliberately keeps a `CMakeLists.txt` of its own (the
1758
+ two-file split `_minimal_app_root_cmake`/`_minimal_app_src_cmake` write),
1759
+ so `app: ./src` sent `west build` straight at `src/CMakeLists.txt` --
1760
+ a bare `target_sources(app ...)` with no `find_package(Zephyr ...)`
1761
+ of its own -- and skipped the root file (with the REAL `find_package`/
1762
+ `project()`) entirely. `app: .` resolves to the project root directly:
1763
+ `_zephyr_app_dir` finds `CMakeLists.txt` right there and returns it
1764
+ without ever consulting `src/`."""
1765
+ return (
1766
+ "# Generated by `tan init`.\n"
1767
+ "# board.yaml describes hardware: the SoM SKU + per-core app map.\n"
1768
+ "# Validate it with `tan validate` once an SDK is resolved.\n"
1769
+ "\n"
1770
+ "som:\n"
1771
+ f" sku: {sku}\n"
1772
+ "cores:\n"
1773
+ f" {app_core_for_sku(sku)}:\n"
1774
+ " os: zephyr\n"
1775
+ " app: .\n"
1776
+ )
1777
+
1778
+
1779
+ def _minimal_app_readme(sku: str) -> str:
1780
+ notes = "".join(f"- {line}\n" for line in _MINIMAL_APP_EXPLANATION)
1781
+ return (
1782
+ "# Alp Starter Project\n"
1783
+ "\n"
1784
+ "Template: minimal-app\n"
1785
+ f"SoM: {sku}\n"
1786
+ f"App core: {app_core_for_sku(sku)} (Zephyr)\n"
1787
+ "\n"
1788
+ "## Generated Starter Notes\n"
1789
+ "\n"
1790
+ f"{notes}"
1791
+ "\n"
1792
+ "## Next Steps\n"
1793
+ "\n"
1794
+ "- Run Alp: Validate board.yaml.\n"
1795
+ "- Run Alp: Generate all to produce derived outputs under build/generated/.\n"
1796
+ "- Extend source files under src/features/ for your target behavior.\n"
1797
+ "\n"
1798
+ "This workspace was generated by Alp: New Project Wizard.\n"
1799
+ "Use Alp commands to validate, generate, and build outputs.\n"
1800
+ )
1801
+
1802
+
1803
+ def _minimal_app_root_cmake() -> str:
1804
+ """tan-cli#309: the file `board.yaml`'s `app: .` now points `west build`
1805
+ at directly, so THIS is the file that has to carry Zephyr's boilerplate --
1806
+ before the fix it was `add_subdirectory(src)` with nothing before it, and
1807
+ `board.yaml`'s `app: ./src` skipped straight past it to `src/CMakeLists.txt`
1808
+ (`_minimal_app_board_yaml`'s docstring has the full mechanism).
1809
+ `find_package(Zephyr ...)` has to run before `project()` -- Zephyr's own
1810
+ convention (every vendored template does the same, e.g.
1811
+ `templates/vendored/minimal/*/CMakeLists.txt`) -- because `find_package`
1812
+ is what resolves the toolchain/board machinery `project()` consumes when it
1813
+ enables the C language; reversing the order leaves `project()` running
1814
+ before Zephyr's own CMake modules are even on `CMAKE_MODULE_PATH`."""
1815
+ return (
1816
+ "cmake_minimum_required(VERSION 3.20.0)\n"
1817
+ "find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})\n"
1818
+ "project(alp_starter C)\n"
1819
+ "\n"
1820
+ "add_subdirectory(src)\n"
1821
+ )
1822
+
1823
+
1824
+ def _minimal_app_src_cmake() -> str:
1825
+ """Contributes to Zephyr's own `app` target via `target_sources`/
1826
+ `target_include_directories` -- never a second `add_executable`, which
1827
+ Zephyr's build never links in (tan-cli#309)."""
1828
+ feature_path = _MINIMAL_APP_FEATURE_FILE[0]
1829
+ rel = feature_path[len("src/") :] if feature_path.startswith("src/") else feature_path
1830
+ return (
1831
+ "target_sources(app PRIVATE\n"
1832
+ " main.c\n"
1833
+ f" {rel}\n"
1834
+ ")\n"
1835
+ "target_include_directories(app PRIVATE ../include)\n"
1836
+ )
1837
+
1838
+
1839
+ def _minimal_app_main_c() -> str:
1840
+ line1, line2 = _MINIMAL_APP_BODY_LINES
1841
+ return (
1842
+ "// SPDX-License-Identifier: Apache-2.0\n"
1843
+ "\n"
1844
+ '#include "app/app.h"\n'
1845
+ "#include <stdio.h>\n"
1846
+ "\n"
1847
+ "int alp_app_init(void) {\n"
1848
+ " // TODO: initialize app-level services.\n"
1849
+ " return 0;\n"
1850
+ "}\n"
1851
+ "\n"
1852
+ "int alp_app_run(void) {\n"
1853
+ " // TODO: execute one app cycle.\n"
1854
+ " return 0;\n"
1855
+ "}\n"
1856
+ "\n"
1857
+ "int main(void) {\n"
1858
+ " if (alp_app_init() != 0) {\n"
1859
+ ' puts("alp_app_init failed");\n'
1860
+ " return 1;\n"
1861
+ " }\n"
1862
+ "\n"
1863
+ " if (alp_app_run() != 0) {\n"
1864
+ ' puts("alp_app_run failed");\n'
1865
+ " return 1;\n"
1866
+ " }\n"
1867
+ "\n"
1868
+ f' puts("{line1}");\n'
1869
+ f' puts("{line2}");\n'
1870
+ " return 0;\n"
1871
+ "}\n"
1872
+ )
1873
+
1874
+
1875
+ def _feature_file(unit_name: str, todo_line: str) -> str:
1876
+ return (
1877
+ "// SPDX-License-Identifier: Apache-2.0\n"
1878
+ "\n"
1879
+ "#include <stdio.h>\n"
1880
+ "\n"
1881
+ f"int {unit_name}_step(void) {{\n"
1882
+ f" // {todo_line}\n"
1883
+ " return 0;\n"
1884
+ "}\n"
1885
+ )
1886
+
1887
+
1888
+ # ---------------------------------------------------------------------------
1889
+ # `--from-example`: copy an SDK example verbatim
1890
+ # ---------------------------------------------------------------------------
1891
+
1892
+
1893
+ #: Directory names under an SDK example that hold BUILD OUTPUT, never example
1894
+ #: source -- pruned by [`read_example_tree`] (tan-cli#494 defect 1).
1895
+ #:
1896
+ #: Transcribed from alp-sdk's OWN `.gitignore`, which is the authority on what
1897
+ #: is untracked artifact in that repo: `tan init --from-example` copies an
1898
+ #: example into a NEW project, and a `west build` run in place turns a 6-file
1899
+ #: example into 613 files of `CMakeCache.txt`/`.ninja_deps`/`libapp.a`. Those
1900
+ #: are not the customer's project, and the first binary among them aborts the
1901
+ #: whole command with `init.example-unreadable`.
1902
+ #:
1903
+ #: The transcription is of TWO blocks, and the round of this fix that shipped
1904
+ #: with tan-cli#583 took only part of the first -- it claimed in this very
1905
+ #: comment to be "EXACTLY the five `.gitignore` patterns" while covering five
1906
+ #: of the SEVEN directory patterns alp-sdk declares:
1907
+ #:
1908
+ #: * `.gitignore:1-6`, the `# Build directories` block -- `build/`, `build_*/`,
1909
+ #: `out/`, `cmake-build-*/`, `bwdt/`. `out/` and `bwdt/` were BOTH missed.
1910
+ #: * `.gitignore:36-37`, the west/twister block -- `twister-out/`,
1911
+ #: `twister-out.*/`. Both were taken.
1912
+ #:
1913
+ #: `out/` is not hypothetical and is not only a `west build -d out` spelling:
1914
+ #: `examples/camera-vision/ai-object-detection-realtime/README.md:83` tells the
1915
+ #: customer, in the example's own instructions, to run `dxcom -m yolov8n.onnx
1916
+ #: -c yolov8n_config.json -o out/` INSIDE the example. Measured with that `out/`
1917
+ #: present: a `.dxnn` blob there fails the whole command with
1918
+ #: `init.example-unreadable`, and an all-text `out/` (Intel HEX is ASCII, so
1919
+ #: `out/zephyr/zephyr.hex` qualifies) is copied silently at `ok:true` / exit 0 /
1920
+ #: `issues: []` -- a stale artefact from someone else's build landing in a brand
1921
+ #: new project. `bwdt/` carries no explanation anywhere in alp-sdk, but it sits
1922
+ #: inside that `# Build directories` block, so the same authority covers it.
1923
+ #:
1924
+ #: Pruning by a `.gitignore`d NAME can only ever drop artefact, never source: a
1925
+ #: name alp-sdk declares untracked cannot BE tracked example content there, and
1926
+ #: `--from-example` reads an alp-sdk checkout by construction
1927
+ #: (`init_cmd._plan_from_example` resolves under `<sdk>/examples`). Measured
1928
+ #: against alp-sdk `origin/dev` 7d58ef32: ZERO tracked files anywhere in that
1929
+ #: repo lie under an `out/` or `bwdt/` path segment. That invariant is what
1930
+ #: makes ADDING a declared pattern safe and INVENTING one unsafe -- an earlier
1931
+ #: cut also pruned `build-`, in no pattern there, which would have silently
1932
+ #: dropped a hand-written `build-utils/` from a customer's new project.
1933
+ #:
1934
+ #: FILE patterns are deliberately out of scope: `.gitignore:55` also declares
1935
+ #: `*.out`, but this mechanism prunes DIRECTORY names off `os.walk`'s
1936
+ #: `dirnames`, and a file-level filter is a different change with a different
1937
+ #: blast radius. No shipped example carries one.
1938
+ #:
1939
+ #: `tests/core/test_scaffold.py::test_the_prune_list_still_covers_alp_sdk_s_own_
1940
+ #: build_directory_gitignore_blocks` re-reads BOTH source blocks out of a bound
1941
+ #: `ALP_SDK_ROOT` checkout and fails when alp-sdk declares a directory that is
1942
+ #: neither pruned here nor recorded there as deliberately kept -- so the next
1943
+ #: one is caught rather than transcribed short a second time.
1944
+ #:
1945
+ #: DELIBERATE DIVERGENCE from the v0.4.1 Rust oracle, which HAD the same missing
1946
+ #: exclusion (`crates/tan-core/src/wizard/filesystem.rs::collect_example_files`,
1947
+ #: deleted along with the rest of `crates/` by tan-cli#269) and failed
1948
+ #: identically -- the oracle was a fixed point for BEHAVIOUR THAT IS RIGHT, and
1949
+ #: copying a build tree into a new project is not. No parity capture pins
1950
+ #: `--from-example` content (`tests/fixtures/oracle_captures/
1951
+ #: PARITY-COVERAGE.txt` covers `scaffold` refusals only), so nothing frozen
1952
+ #: moves for this.
1953
+ #:
1954
+ #: Three more references to the now-deleted `crates/` tree survive in this file
1955
+ #: (lines 5, 933 and 1048) as history of where this module was ported from;
1956
+ #: they are pre-existing and repo-wide, and are left for a sweep of their own.
1957
+ _EXAMPLE_BUILD_OUTPUT_DIRS = ("build", "out", "bwdt", "twister-out")
1958
+ _EXAMPLE_BUILD_OUTPUT_PREFIXES = ("build_", "cmake-build-", "twister-out.")
1959
+
1960
+
1961
+ def _is_build_output_dir(name: str) -> bool:
1962
+ """Whether a directory NAME under an example is untracked build output.
1963
+
1964
+ Exact-match names and `startswith` prefixes are kept apart on purpose: the
1965
+ `.gitignore` patterns behind the first group carry no `*`, so `out/` must
1966
+ NOT match `outputs/` or `outbox/` and `build/` must not match
1967
+ `build-utils/`. Only the three patterns that really are globs
1968
+ (`build_*/`, `cmake-build-*/`, `twister-out.*/`) get prefix treatment.
1969
+ """
1970
+ return name in _EXAMPLE_BUILD_OUTPUT_DIRS or name.startswith(
1971
+ _EXAMPLE_BUILD_OUTPUT_PREFIXES
1972
+ )
1973
+
1974
+
1975
+ def _example_source_files(source_dir: Path) -> list[Path]:
1976
+ """Every regular file under `source_dir` that is example SOURCE: build-output
1977
+ directories pruned, symlinks skipped on BOTH the file and the directory side.
1978
+
1979
+ `os.walk`, not `Path.rglob("*")`, for two reasons the walk needs and the
1980
+ glob cannot give: `dirnames` is mutable, which is what makes pruning a
1981
+ build tree cost nothing instead of stat-ing all 607 files inside it; and
1982
+ `onerror` surfaces a directory that could not be listed instead of
1983
+ `rglob`'s silent skip (`_vendored_files` needs the same guarantee -- see
1984
+ tan-cli#494 defect 3).
1985
+
1986
+ Symlinks are skipped, matching the oracle's `DirEntry::file_type()`, whose
1987
+ own doc comment says "Symlinks and other non-regular entries are skipped":
1988
+ `Path.is_file()` FOLLOWS a symlinked file, so a link pointing outside the
1989
+ example would inline that outside file's content into the customer's new
1990
+ project. `followlinks` stays at its `False` default for the directory side.
1991
+ """
1992
+
1993
+ def _raise(err: OSError) -> None:
1994
+ raise err
1995
+
1996
+ found: list[Path] = []
1997
+ for dirpath, dirnames, filenames in os.walk(source_dir, onerror=_raise):
1998
+ dirnames[:] = [d for d in dirnames if not _is_build_output_dir(d)]
1999
+ here = Path(dirpath)
2000
+ for name in filenames:
2001
+ candidate = here / name
2002
+ # `is_symlink()` FIRST: a dangling link is not `is_file()` either,
2003
+ # and the two must not be distinguishable here.
2004
+ if candidate.is_symlink() or not candidate.is_file():
2005
+ continue
2006
+ found.append(candidate)
2007
+ return sorted(found, key=lambda p: p.relative_to(source_dir).as_posix())
2008
+
2009
+
2010
+ def read_example_tree(source_dir: Path) -> list[PlannedFile]:
2011
+ """Read every regular file under `source_dir` verbatim as UTF-8 text, paths
2012
+ forward-slash normalised and sorted by that relative path (NOT by `Path` --
2013
+ see `_vendored_files` for why that ordering is platform-dependent).
2014
+
2015
+ Build output is NOT example source and never reaches the new project --
2016
+ see `_example_source_files`, which also does the sorting and the
2017
+ symlink/unlistable-directory handling.
2018
+
2019
+ UTF-8 only, like the Rust: a binary-carrying example surfaces as an
2020
+ `ExampleReadError` instead of being copied corrupt. All shipped examples are
2021
+ text today -- and with build output pruned, that is true of a built-in-place
2022
+ checkout too.
2023
+
2024
+ tan-cli#1116 round 2: NOT an `is_dir()` pre-flight -- a first pass here
2025
+ guarded `source_dir.is_dir()` with `except OSError`, reasoning that
2026
+ `Path.is_dir()` swallows only `ENOENT`/`ENOTDIR`/`EBADF`/`ELOOP`
2027
+ (`pathlib`'s own `_IGNORED_ERRNOS`), not `EACCES`. That RAISING
2028
+ BEHAVIOUR held on 3.12.3 AND 3.13.15 (`is_dir()` on a permission-denied
2029
+ ancestor still raises `PermissionError`, `errno=13`, on both) and
2030
+ stopped holding only in 3.14.7, where `is_dir()` swallows EVERY
2031
+ `OSError` -- `EACCES` included -- and returns `False`; the
2032
+ `_IGNORED_ERRNOS` CONSTANT the reasoning named is a release earlier
2033
+ than that (gone from `pathlib` since 3.13, `AttributeError` probing for
2034
+ it there), so a guard keyed on either "the constant still exists" or
2035
+ "I measured the raise on the interpreters I have" is wrong on some
2036
+ supported interpreter -- the boundary is behavioural, on 3.14 alone,
2037
+ and does not track the constant's own removal a release earlier. So the
2038
+ guarded pre-flight was version-dependent and silently dead only on
2039
+ 3.14.7 (measured: seam1's CI leg runs CPython 3.14.7 exactly). On that
2040
+ interpreter a permission-denied parent fell through to
2041
+ `is_source_dir is False`, which this function itself raises as
2042
+ `not_found=True` -- the "user's typo" arm -- the exact wrong answer
2043
+ this docstring's own words rule out two paragraphs up. The ELOOP shape
2044
+ is a release EARLIER still: `is_dir()` on a symlink loop already
2045
+ returns `False` (never raises) on 3.12.3 and 3.13.15 alike, so
2046
+ `not_found` was wrong for THAT shape on every interpreter this function
2047
+ ships on, not merely on 3.14 -- the version-independent rewrite below
2048
+ fixes it everywhere at once rather than chasing a second boundary.
2049
+
2050
+ The fix: no stat call at all. `_example_source_files`'s `os.walk(...,
2051
+ onerror=_raise)` already re-raises whatever `os.scandir` cannot get past
2052
+ -- `FileNotFoundError` for a genuinely absent path, `NotADirectoryError`
2053
+ for `source_dir` itself (or an ancestor) being a plain file,
2054
+ `PermissionError`/other `OSError` (ELOOP included) for everything else
2055
+ -- so calling it directly and classifying by the REAL exception is
2056
+ version-independent by construction, the same shape `validate_document`
2057
+ (`metadata_schema.py`) and `_resolve_hw_rev` (`perf_apply.py`) now use.
2058
+ `FileNotFoundError`/`NotADirectoryError` keep the ORIGINAL "not a
2059
+ directory" wording and `not_found=True` -- both are "this is not a real
2060
+ example directory," a typo-shaped answer, exactly what the pre-existing
2061
+ `is_dir() is False` branch covered for those two causes. Every other
2062
+ failure (a permission-denied ancestor, an ELOOP symlink loop, a
2063
+ non-UTF-8 file once inside a directory that DID open) is `not_found=
2064
+ False`: a directory this cannot even read is a runtime failure, never
2065
+ the customer's typo.
2066
+ """
2067
+ files: list[PlannedFile] = []
2068
+ try:
2069
+ for path in _example_source_files(source_dir):
2070
+ files.append(
2071
+ PlannedFile(path.relative_to(source_dir).as_posix(), _read_verbatim(path))
2072
+ )
2073
+ except (FileNotFoundError, NotADirectoryError) as err:
2074
+ # tan-cli#1116 review round 3: str(err), not a hardcoded
2075
+ # f"'{source_dir}' is not a directory." -- this handler catches
2076
+ # whatever `os.walk`'s `onerror=_raise` re-raises from ANYWHERE in
2077
+ # the tree it is walking, not only from `source_dir` itself. A
2078
+ # NESTED directory that changes shape between being listed (as a
2079
+ # directory, into `dirnames`) and being descended into (a genuine
2080
+ # TOCTOU window `os.walk` does not close) raises this exact pair of
2081
+ # exceptions from THAT path, not `source_dir` -- a hardcoded
2082
+ # message blaming `source_dir` would misattribute the failure to
2083
+ # the wrong directory. `str(err)` uses Python's own message, which
2084
+ # names the REAL path that failed (`FileNotFoundError`'s and
2085
+ # `NotADirectoryError`'s own `__str__` already include it), so this
2086
+ # is accurate whichever path in the tree actually broke.
2087
+ raise ExampleReadError(str(err), not_found=True) from err
2088
+ except (OSError, UnicodeDecodeError) as err:
2089
+ # UnicodeDecodeError is a ValueError, not an OSError -- catching only
2090
+ # OSError would let a binary file in an example escape as a traceback.
2091
+ raise ExampleReadError(str(err), not_found=False) from err
2092
+ return files
2093
+
2094
+
2095
+ # ---------------------------------------------------------------------------
2096
+ # Disk: diff, then write
2097
+ # ---------------------------------------------------------------------------
2098
+
2099
+
2100
+ def _existing_content(path: Path) -> str | None:
2101
+ """The file's content, or None when it is absent/unreadable -- an unreadable
2102
+ file compares unequal to anything, so it is planned as an `update`."""
2103
+ try:
2104
+ return _read_verbatim(path)
2105
+ except (OSError, UnicodeDecodeError):
2106
+ return None
2107
+
2108
+
2109
+ def collect_file_changes(project_root: Path, files: list[PlannedFile]) -> list[FileChange]:
2110
+ """Classify each planned file as `new` / `update` / `unchanged` relative to
2111
+ `project_root`. Read-only: `--preview` stops after this."""
2112
+ changes = []
2113
+ for planned in files:
2114
+ path = project_root / planned.relative_path
2115
+ if not path.exists():
2116
+ kind = "new"
2117
+ else:
2118
+ existing = _existing_content(path)
2119
+ kind = "unchanged" if existing == planned.content else "update"
2120
+ changes.append(FileChange(planned.relative_path, kind))
2121
+ return changes
2122
+
2123
+
2124
+ def write_files(project_root: Path, files: list[PlannedFile]) -> WriteResult:
2125
+ """Write every planned file under `project_root`, creating parents as
2126
+ needed. A file whose content already matches is skipped and counted
2127
+ `unchanged`.
2128
+
2129
+ Every planned target is confined to `project_root` -- after symlink
2130
+ resolution -- BEFORE anything is written: a pre-existing symlinked (or
2131
+ junctioned) parent directory, or a symlinked existing leaf, that would
2132
+ carry a write outside the project refuses the WHOLE run rather than
2133
+ writing through it and reporting the in-project logical path as written
2134
+ (tan-cli#325). A symlinked project ROOT itself is unaffected -- see
2135
+ `resolve_confined`.
2136
+
2137
+ Raises `ScaffoldWriteError` carrying the partial result on the first
2138
+ failure -- an unwritable destination, a path component that is a file
2139
+ rather than a directory, a read-only tree, a full disk, or the escape
2140
+ guard above. Every one of those is the user's environment, not a tan bug,
2141
+ so none may escape as a traceback.
2142
+ """
2143
+ targets: list[tuple[PlannedFile, Path]] = []
2144
+ for planned in files:
2145
+ try:
2146
+ target = resolve_confined(project_root, project_root / planned.relative_path)
2147
+ except (PathEscapeError, OSError, ValueError) as err:
2148
+ raise ScaffoldWriteError(
2149
+ f"refusing to write '{planned.relative_path}': {err}",
2150
+ WriteResult([], []),
2151
+ ) from err
2152
+ targets.append((planned, target))
2153
+
2154
+ written: list[str] = []
2155
+ unchanged: list[str] = []
2156
+ for planned, target in targets:
2157
+ if target.exists() and _existing_content(target) == planned.content:
2158
+ unchanged.append(planned.relative_path)
2159
+ continue
2160
+ try:
2161
+ target.parent.mkdir(parents=True, exist_ok=True)
2162
+ _write_verbatim(target, planned.content)
2163
+ except OSError as err:
2164
+ raise ScaffoldWriteError(str(err), WriteResult(written, unchanged)) from err
2165
+ written.append(planned.relative_path)
2166
+ return WriteResult(written, unchanged)
2167
+
2168
+
2169
+ def scaffold_tree_preview(files: list[PlannedFile]) -> str:
2170
+ """The planned files as an ASCII `tree`-style listing, paths sorted, last
2171
+ entry marked ``\\`-- ``. Text mode only -- JSON callers read
2172
+ `data.fileChanges`."""
2173
+ paths = sorted(f.relative_path for f in files)
2174
+ lines = ["."]
2175
+ for i, path in enumerate(paths):
2176
+ lines.append(f"{'`--' if i + 1 == len(paths) else '|--'} {path}")
2177
+ return "\n".join(lines) + "\n"
2178
+
2179
+
2180
+ def sdk_pointer_json(sdk_path: str) -> str:
2181
+ """The `.alp/sdk-path` pointer file's contents, so a new project is
2182
+ reproducible without a separate `tan sdk switch`. Shape and two-space indent
2183
+ from `commands::sdk::write_sdk_pointer`; the path is recorded exactly as the
2184
+ caller resolved it.
2185
+
2186
+ `SOURCE_DATE_EPOCH` wins over the clock, so a captured pointer is
2187
+ reproducible -- through `tan.core.timestamp`, which NEVER raises. This runs
2188
+ AFTER the customer's project files already landed, and an out-of-range epoch
2189
+ (the MILLISECONDS case) used to kill `tan init` with a traceback here.
2190
+ """
2191
+ import json # noqa: PLC0415 -- one call site, not worth a module-level import
2192
+
2193
+ return (
2194
+ json.dumps({"sdkPath": sdk_path, "updatedAt": generated_at_iso()}, indent=2)
2195
+ + "\n"
2196
+ )
2197
+
2198
+
2199
+ def posix(path: Path) -> str:
2200
+ """`path` as a string with forward slashes -- what the envelope's path-shaped
2201
+ fields carry (the conformance harness normalises `\\` -> `/` on those keys,
2202
+ and a golden is authored in the normalised form)."""
2203
+ return str(path).replace(os.sep, "/")