tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,535 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """SoM/board-metadata reads for `template.py`'s `--emit scaffold` sku swap.
3
+
4
+ SPLIT out of `tan/planner/template.py` (tan-cli#1142; that module was 2206
5
+ lines against `MODULE_CAP = 800`). This is NOT a second hand-port: both this
6
+ module and `template_rewrite.py` are the SAME `HAND_PORT_SOURCES` entry
7
+ (`scripts/alp_template.py`) as `template.py` itself -- see
8
+ `test_planner_relocation_freshness.py::HAND_PORT_SOURCES` and
9
+ `test_hand_port_tan_side.py::HAND_PORT_TAN_SIDE`, both of which map all three
10
+ tan files to the one upstream source, the same many-to-one shape
11
+ `alp_project_loader.py` already has for `project_loader.py` + `som_metadata.py`.
12
+
13
+ `_module_size_budget_core.MIRRORED_PREFIX` ("upstream's to split, not this
14
+ repo's") does NOT bar this split: that comment describes `PINNED_HASHES`
15
+ modules, which are 3-way MERGED against a moving upstream file and would
16
+ conflict on every future upstream hunk if they diverged in shape here.
17
+ `template.py` is `HAND_PORT_SOURCES`-tracked instead -- FLAGGED, never merged
18
+ (`test_planner_relocation_freshness.py`'s own module docstring) -- so there is
19
+ no base/theirs/ours triple for a shape change to break. See
20
+ `_module_size_budget_core.py`'s corrected `MIRRORED_PREFIX` comment and
21
+ `test_module_size_budget.py::test_the_mirrored_planner_is_named_as_out_of_scope`,
22
+ which now checks only the `PINNED_HASHES` subset.
23
+
24
+ WHAT'S HERE: every read of `metadata/e1m_modules/<sku>.yaml` /
25
+ `metadata/boards/<board>.yaml` and the sku/core/pin RE-DERIVATION this module's
26
+ functions compute from them -- `_load_som_doc` through `_derive_pin_doc_renames`,
27
+ plus `_core_board` (moved here despite sitting elsewhere in the original file,
28
+ alongside the other `_topology_for_sku` reader rather than beside the
29
+ `board.yaml` TEXT rewriters in `template_rewrite.py`, which is where it lived
30
+ physically before the split). Every function here reads metadata and returns a
31
+ plain value or a rename map; none of them touch `board.yaml`/CMakeLists.txt/
32
+ README.md TEXT -- that half is `template_rewrite.py`.
33
+
34
+ Every docstring below is UNCHANGED from `template.py` -- issue numbers, tan-cli
35
+ numbers and worked examples all still refer to that module's own history,
36
+ because this split moved code, not its provenance.
37
+
38
+ Imports `TemplateError` / `_require_field` / `_read_yaml_mapping` back FROM
39
+ `.template` rather than the reverse: `template.py` still owns the
40
+ `DocumentGuards(TemplateError)` binding (`_GUARDS` and friends), since
41
+ `_rendered_bytes`/`render_to_envelope` stayed there and need
42
+ it too, and `cli.py`'s `from .template import (TemplateError, emit_scaffold,
43
+ find_template_by_cores, load_catalog)` must keep resolving unchanged. That
44
+ makes the import direction CIRCULAR at the module-object level (`template.py`
45
+ imports names from this module for its own re-exports, and this module imports
46
+ `TemplateError`/`_require_field`/`_read_yaml_mapping` back from `template.py`)
47
+ -- which works only because `template.py` binds all three before its own
48
+ `from .template_pins import ...` line runs; see that file's own comment at the
49
+ same spot. Do not move this module's import of `.template` above where
50
+ `template.py` defines those three names, and do not move `template.py`'s
51
+ import of this module above its own `_GUARDS` block.
52
+ """
53
+ from __future__ import annotations
54
+
55
+ from pathlib import Path
56
+ from typing import Any
57
+
58
+ from .template import TemplateError, _read_yaml_mapping, _require_field
59
+
60
+
61
+ def _load_som_doc(sku: str, metadata_root: Path) -> dict[str, Any]:
62
+ """Parse metadata/e1m_modules/<sku>.yaml -- shared by
63
+ `_default_preset_for_sku` (the `default_board:` field),
64
+ `_derive_core_renames` (the `topology:` block), and `_core_board`
65
+ (also `topology:`), so all three read the exact same doc for the
66
+ same `(sku, metadata_root)`."""
67
+ som_path = metadata_root / "e1m_modules" / f"{sku}.yaml"
68
+ # Mirrors `resolve_targets`'s `preset` guard (tan-cli#1010,
69
+ # `tan/model/targets.py:312-323`): a SoM YAML that parses but is not
70
+ # a mapping (e.g. a bare list or a bare scalar) must not reach a
71
+ # caller's bare `.get(...)` -- every caller of this function does
72
+ # exactly that -- which raises a raw AttributeError instead of a
73
+ # curated error a caller can distinguish from any other such error
74
+ # in the call stack (tan-cli#1025). Shared with the module's two
75
+ # other outer-document reads since tan-cli#1052; the message is
76
+ # unchanged.
77
+ #
78
+ # tan-cli#1133: the READ and the PARSE are guarded too, and the
79
+ # `is_file()` pre-flight this used to open with is gone -- see
80
+ # `_read_yaml_mapping`. The missing-file message is byte-identical to
81
+ # the one that pre-flight raised (pinned by `tests/planner/
82
+ # test_render_to_envelope_malformed_example_board.py`).
83
+ return _read_yaml_mapping(
84
+ som_path, what="SoM preset",
85
+ absent=f"no metadata/e1m_modules/{sku}.yaml for sku {sku!r}")
86
+
87
+
88
+ def _default_preset_for_sku(sku: str, metadata_root: Path) -> str:
89
+ """The board preset a fresh project targeting `sku` ships with by
90
+ default -- metadata/e1m_modules/<sku>.yaml's `default_board:`,
91
+ lower-cased to match the `preset:` value every example board.yaml
92
+ already uses (e.g. `E1M-EVK` -> `e1m-evk`, `E1M-X-EVK` ->
93
+ `e1m-x-evk`). This is the SAME field board.yaml's own comments point
94
+ customers at by hand ("copy this directory, change som.sku ...,
95
+ edit the preset:") -- render_to_envelope just does that edit for
96
+ them."""
97
+ board = _load_som_doc(sku, metadata_root).get("default_board")
98
+ if not board:
99
+ raise TemplateError(
100
+ f"metadata/e1m_modules/{sku}.yaml has no default_board")
101
+ # Same document-class guard as `_load_som_doc` itself (tan-cli#1025
102
+ # sweep): a `default_board:` that parses but isn't a scalar string
103
+ # (e.g. a YAML list) must not reach `.lower()` bare -- that raises
104
+ # `AttributeError: 'list' object has no attribute 'lower'` instead
105
+ # of a curated error naming the file.
106
+ _require_field(board, str, doc=f"metadata/e1m_modules/{sku}.yaml",
107
+ field="default_board")
108
+ return board.lower()
109
+
110
+
111
+ def _topology_for_sku(sku: str, metadata_root: Path) -> dict[str, Any]:
112
+ """`metadata/e1m_modules/<sku>.yaml` `topology:` -- shared by
113
+ `_derive_core_renames` and `_core_board`, both of which key into the
114
+ result by core id (`.items()`/`.get(core_id)`) and then read a
115
+ `board:`/`app:` field off each core's own entry (`spec.get(...)`).
116
+
117
+ Same document-class guard as `_load_som_doc` itself (tan-cli#1025
118
+ sweep, round 2 -- the first round guarded the outer document but
119
+ left every nested read this function serves unguarded): a
120
+ `topology:` block that parses but is not itself a mapping (e.g. a
121
+ bare list) must not reach either caller's bare `.items()`/`.get()`
122
+ -- that raises a raw AttributeError instead of a curated error
123
+ naming the file and the actual type. Guarding the OUTER shape here,
124
+ once, also closes a quieter failure mode a plain per-caller
125
+ AttributeError guard would not: `_derive_core_renames`'s own `cid
126
+ not in topology` membership test does not raise against a list
127
+ `topology` at all (a list supports `in`), so an unguarded list would
128
+ silently resolve `stale == []` and return `None` -- a byte-
129
+ identical-passthrough verdict that is not true, instead of
130
+ surfacing the malformed document.
131
+
132
+ Each core's own entry is validated too, for the same reason one
133
+ level down: `_derive_core_renames`'s `spec.get("board")` and
134
+ `_core_board`'s `topology.get(core_id).get("board")` both assume
135
+ the per-core value is itself a mapping. A `topology:` that IS a
136
+ mapping but whose value for some core id is a bare list/scalar
137
+ (legal YAML, illegal `som-preset` schema) would otherwise reach
138
+ those bare `.get()` calls unguarded -- validating every entry here,
139
+ once, closes that for both callers the same way the outer check
140
+ does."""
141
+ som_yaml = f"metadata/e1m_modules/{sku}.yaml"
142
+ topology = _require_field(
143
+ _load_som_doc(sku, metadata_root).get("topology") or {}, dict,
144
+ doc=som_yaml, field="topology")
145
+ for core_id, spec in topology.items():
146
+ _require_field(spec, dict, doc=som_yaml, field=f"topology.{core_id}")
147
+ return topology
148
+
149
+
150
+ def _derive_core_renames(
151
+ original_core_ids: list[str], sku: str, metadata_root: Path,
152
+ ) -> dict[str, str] | None:
153
+ """Re-derive every STALE core id a catalog template's `cores:`
154
+ block declares, for `sku`'s OWN SoM topology (issue #864 follow-up:
155
+ the shallow "byte-copy the example + swap som.sku" `render_to_
156
+ envelope()` #864 shipped hard-coded the CANONICAL example's own
157
+ core id -- e.g. `m55_hp`, an Alif-only Zephyr cluster -- into every
158
+ substituted board.yaml, emitting a non-buildable
159
+ scaffold for any cross-SoM-family sku: `alp_project.py --emit
160
+ zephyr-conf --core m55_hp` against an E1M-V2N101 board.yaml fails
161
+ with rc=1, "unknown core id ... did you mean ['a55_cluster',
162
+ 'm33_sm']").
163
+
164
+ EVERY key `cores:` declares must exist in the target sku's own
165
+ topology -- `alp_orchestrate.loader._validate_topology_cores` hard-
166
+ errors on an unmatched key unconditionally, whether or not that
167
+ core is `os: off` -- so a template that also declares the OTHER
168
+ cluster explicitly disabled (edge-ai's `cores.a32_cluster: {os:
169
+ off}`, alongside the active `m55_hp`) needs THAT id renamed too,
170
+ not just the one core the app actually runs on.
171
+
172
+ Returns `None` when every declared id already exists in `sku`'s
173
+ `metadata/e1m_modules/<sku>.yaml` `topology:` -- the canonical
174
+ example's own SoM, or a same-family sibling that shares its core
175
+ ids -- a byte-identical passthrough, nothing to rewrite. Otherwise
176
+ returns `{old_core_id: new_core_id, ...}` for every stale id: each
177
+ replacement is `sku`'s own topology core sharing the same leading
178
+ core-class letter (`m`/`a`), additionally requiring a Zephyr
179
+ `board:` target for an `m`-class replacement (only that core is
180
+ ever `--core`-buildable); an `a`-class utility core carries
181
+ no such requirement (it's only ever `os: off` in every template
182
+ that declares one today).
183
+
184
+ Candidates are picked in `topology:`'s OWN declaration order, NOT
185
+ alphabetically (issue #864 Fable-review MAJOR D): a multi-m-core
186
+ SoM's PRIMARY app core is whichever one the SoM preset author
187
+ listed first, not whichever sorts first -- E1M-AEN801's `topology:`
188
+ declares `m55_hp` (the real app core) before `m55_he` (a stock-shim
189
+ peer core), but `m55_he` sorts first alphabetically.
190
+ `alp_project_emit.hw_info._pick_primary_core_os` picks its "primary
191
+ core" alphabetically -- that convention is NOT reused here for
192
+ exactly this reason; it would silently rename onto the wrong core
193
+ the day any multi-m-core SKU joins a template's supported set
194
+ (verified: `_derive_core_renames(["m33_sm"], "E1M-AEN801", ...)`
195
+ resolved `m55_he`, not the real app core `m55_hp`, before this
196
+ fix -- unreachable today, since no template's `supported.som_skus`
197
+ combo exercises it, but latently wrong).
198
+ """
199
+ topology = _topology_for_sku(sku, metadata_root)
200
+ stale = [cid for cid in original_core_ids if cid not in topology]
201
+ if not stale:
202
+ return None
203
+ claimed = set(original_core_ids) & set(topology)
204
+ renames: dict[str, str] = {}
205
+ for old in stale:
206
+ prefix = old[0]
207
+ require_board = prefix == "m"
208
+ candidates = [
209
+ cid for cid, spec in topology.items()
210
+ if cid.startswith(prefix) and cid not in claimed
211
+ and cid not in renames.values()
212
+ and (spec.get("board") if require_board else True)
213
+ ]
214
+ if not candidates:
215
+ raise TemplateError(
216
+ f"metadata/e1m_modules/{sku}.yaml topology has no "
217
+ f"{prefix!r}-class core"
218
+ + (" with a Zephyr `board:` target" if require_board else "")
219
+ + f" to replace {old!r}")
220
+ renames[old] = candidates[0]
221
+ return renames
222
+
223
+
224
+ _ROUTE_SECTIONS = ("gpio", "buses", "pwm", "adc", "dac", "i2s", "can", "qenc")
225
+
226
+
227
+ def _board_route_entries(board_name: str, metadata_root: Path) -> list[dict[str, Any]]:
228
+ """Every metadata/boards/<board_name>.yaml `e1m_routes:` entry,
229
+ flattened across sections -- mirrors
230
+ scripts/gen_portability_matrix.py's `_route_entries` (same section
231
+ list, same join; mirrored here rather than imported across module
232
+ boundaries for a handful of lines).
233
+
234
+ Same document-class guard as `_load_som_doc`/`_topology_for_sku`
235
+ (tan-cli#1037, its own round-two amendment, plus a same-shape
236
+ third level a PR #1048 review found still bare): a `metadata/
237
+ boards/<board_name>.yaml` that parses but is not itself a mapping
238
+ (e.g. a bare list) must not reach the outer `.get("e1m_routes")`
239
+ bare; an `e1m_routes:` that parses but is not itself a mapping
240
+ must not reach the per-section `.get(section)` one line down; and
241
+ a per-section value (`e1m_routes.<section>`) that parses but is
242
+ not itself a list must not reach the `for entry in ...` below --
243
+ all three raised a raw `AttributeError`/`TypeError` instead of a
244
+ curated error naming the file, the section, and the actual type
245
+ (tan-cli#1037's Measured, PR #1034's round-two review, and PR
246
+ #1048's review, respectively). The third level mirrors
247
+ `_topology_for_sku`'s own outer-plus-per-entry shape: guarding
248
+ every entry, not just the container, closes the same class one
249
+ level down that guarding only `e1m_routes:` itself would leave
250
+ open."""
251
+ board_path = metadata_root / "boards" / f"{board_name}.yaml"
252
+ # tan-cli#1133, same change as `_load_som_doc`'s: no `is_file()`
253
+ # pre-flight, and the read and the parse are curated rather than bare.
254
+ doc = _read_yaml_mapping(
255
+ board_path, what="board metadata",
256
+ absent=f"no metadata/boards/{board_name}.yaml for board {board_name!r}")
257
+ routes = _require_field(doc.get("e1m_routes") or {}, dict,
258
+ doc=board_path, field="e1m_routes")
259
+ for section in _ROUTE_SECTIONS:
260
+ _require_field(routes.get(section) or [], list,
261
+ doc=board_path, field=f"e1m_routes.{section}")
262
+ return [
263
+ entry for section in _ROUTE_SECTIONS
264
+ for entry in (routes.get(section) or [])
265
+ if isinstance(entry, dict)
266
+ ]
267
+
268
+
269
+ def _board_alias_to_entry(board_name: str, metadata_root: Path) -> dict[str, dict[str, Any]]:
270
+ """{board_alias: route_entry} -- mirrors
271
+ scripts/gen_portability_matrix.py's `_route_by_alias`."""
272
+ out: dict[str, dict[str, Any]] = {}
273
+ for entry in _board_route_entries(board_name, metadata_root):
274
+ alias = entry.get("board_alias")
275
+ if isinstance(alias, str):
276
+ out[alias] = entry
277
+ return out
278
+
279
+
280
+ def _pin_pad_and_macro(item: Any) -> tuple[str | None, str | None]:
281
+ """A `pins:` list item is either a bare E1M pad string, or a
282
+ `{e1m, macro?, doc?}` mapping -- normalise both shapes to
283
+ `(pad, macro)`, `None` for whichever half a bare string doesn't
284
+ carry."""
285
+ if isinstance(item, str):
286
+ return item, None
287
+ if isinstance(item, dict):
288
+ pad = item.get("e1m")
289
+ macro = item.get("macro")
290
+ return (
291
+ pad if isinstance(pad, str) else None,
292
+ macro if isinstance(macro, str) else None,
293
+ )
294
+ return None, None
295
+
296
+
297
+ def _alias_for_pin(entries: list[dict[str, Any]], pad: str, macro: str | None) -> str | None:
298
+ """Resolve a `pins:` entry's `board_alias` on its OWN board --
299
+ MACRO-FIRST (issue #876 review MAJOR 1). A pad can carry more
300
+ than one `board_alias:` (e.g. e1m-evk's `E1M_PWM1` is BOTH
301
+ `BOARD_PWM_LED_BLUE` and `BOARD_PWM_ARD1`, at different `macro:`
302
+ entries), and macro names are unique per board -- every one is
303
+ compiled into a single C header (scripts/gen_board_header.py), so
304
+ two entries sharing a macro would be a duplicate-symbol build
305
+ error -- so matching by `macro:` first is unambiguous.
306
+
307
+ Only when the pin carries no `macro:` at all (a bare pad-string
308
+ entry) does this fall back to matching by `e1m:` alone, and only
309
+ when exactly ONE route entry claims that pad: a bare-string entry
310
+ naming a multi-alias pad has nothing to disambiguate with, so
311
+ `None` is returned (the caller hard-errors) rather than silently
312
+ picking whichever entry happens to come first in the board's
313
+ `e1m_routes:` -- the same class of silent-wrong-pin bug a naive
314
+ `{alias: pad}` dict inversion (this function's predecessor) had
315
+ for the macro-bearing case."""
316
+ if macro is not None:
317
+ for entry in entries:
318
+ if entry.get("macro") == macro:
319
+ alias = entry.get("board_alias")
320
+ return alias if isinstance(alias, str) else None
321
+ return None
322
+ matches = [entry for entry in entries if entry.get("e1m") == pad]
323
+ if len(matches) != 1:
324
+ return None
325
+ alias = matches[0].get("board_alias")
326
+ return alias if isinstance(alias, str) else None
327
+
328
+
329
+ def _resolve_pin_target(
330
+ item: Any, sku: str, source_preset: str, metadata_root: Path,
331
+ ) -> dict[str, Any] | None:
332
+ """Resolve ONE `pins:` entry to its target-board route entry
333
+ (issue #876, hardened by the review's MAJOR 1): looks up the
334
+ entry's `board_alias` on `source_preset` via `_alias_for_pin`
335
+ (macro-first, so a multi-alias pad resolves to the RIGHT alias
336
+ instead of whichever wins a lossy pad->alias dict inversion), then
337
+ looks that alias up on `sku`'s own target board preset.
338
+
339
+ Returns `None` when `sku`'s own default board preset IS
340
+ `source_preset` -- the canonical example's own sku, or a same-
341
+ family sibling that shares its board preset -- a byte-identical
342
+ passthrough, nothing to resolve.
343
+
344
+ DESIGN DECISION (maintainer-approved default): a pad with no
345
+ unambiguous `board_alias:` on `source_preset` -- no cross-EVK
346
+ correspondence declared for that role at all, or a multi-alias
347
+ pad with no `macro:` to disambiguate (issue #876 review MINOR 3)
348
+ -- is a hard error, same philosophy as `_derive_core_renames`'s
349
+ missing-core-class error: a genuinely unsupportable combo must
350
+ fail loudly here, never emit a `pins:` entry that's silently
351
+ stale (or a scaffold `--emit zephyr-conf` then rejects)."""
352
+ target_preset = _default_preset_for_sku(sku, metadata_root)
353
+ if target_preset == source_preset:
354
+ return None
355
+ pad, macro = _pin_pad_and_macro(item)
356
+ if pad is None:
357
+ return None
358
+ alias = _alias_for_pin(_board_route_entries(source_preset, metadata_root), pad, macro)
359
+ if alias is None:
360
+ raise TemplateError(
361
+ f"metadata/boards/{source_preset}.yaml `e1m_routes:` has no "
362
+ f"unambiguous `board_alias:` for pins: entry {item!r} -- can't "
363
+ f"re-derive it for sku {sku!r} (board {target_preset!r})")
364
+ target_entry = _board_alias_to_entry(target_preset, metadata_root).get(alias)
365
+ if target_entry is None:
366
+ raise TemplateError(
367
+ f"metadata/boards/{target_preset}.yaml `e1m_routes:` has no "
368
+ f"route for board_alias {alias!r} (needed to re-derive pins: "
369
+ f"entry {item!r} for sku {sku!r})")
370
+ return target_entry
371
+
372
+
373
+ def _derive_pin_renames(
374
+ original_pins: list[Any], sku: str, source_preset: str, metadata_root: Path,
375
+ ) -> dict[str, str]:
376
+ """Re-derive every catalog template's `pins:` entries -- each an
377
+ E1M pad name (`E1M_GPIO_IO4`) taken from the CANONICAL example's
378
+ own board preset (`source_preset`, e.g. `e1m-evk`) -- for `sku`'s
379
+ OWN default board preset (issue #876: the #864/#877 stopgap that
380
+ dropped E1M-V2N101 from `peripheral`/`sensor`/`edge-ai`'s
381
+ `supported.som_skus` rather than shipping a scaffold whose `pins:`
382
+ block named an E1M-EVK-only pad that an E1M-X-EVK-resolved
383
+ board.yaml's `e1m_routes:` doesn't have).
384
+
385
+ Each item is resolved via `_resolve_pin_target` (macro-first
386
+ `board_alias` match -- see its docstring for the multi-alias
387
+ fix). Returns `{}` when `sku`'s own default board preset IS
388
+ `source_preset` -- a byte-identical passthrough, nothing to
389
+ rewrite. Raises if two DIFFERENT `pins:` entries name the SAME
390
+ source pad but resolve to two different target pads (only
391
+ possible if a template ever lists one pad twice under two
392
+ different `board_alias` roles) -- ambiguous for the flat
393
+ `{old_pad: new_pad}` map `_substitute_board_yaml_pins` applies
394
+ across the whole file, so this fails loudly rather than silently
395
+ keeping whichever resolution happened to run last."""
396
+ renames: dict[str, str] = {}
397
+ for item in original_pins:
398
+ target = _resolve_pin_target(item, sku, source_preset, metadata_root)
399
+ if target is None:
400
+ continue
401
+ pad, _ = _pin_pad_and_macro(item)
402
+ new_pad = target.get("e1m")
403
+ if not isinstance(new_pad, str):
404
+ raise TemplateError(
405
+ f"metadata/boards/{_default_preset_for_sku(sku, metadata_root)}"
406
+ f".yaml route for pins: entry {item!r} has no `e1m:` pad")
407
+ if new_pad == pad:
408
+ continue
409
+ if pad in renames and renames[pad] != new_pad:
410
+ raise TemplateError(
411
+ f"pad {pad!r} re-derives to two different targets "
412
+ f"({renames[pad]!r} and {new_pad!r}) across `pins:` "
413
+ f"entries for sku {sku!r} -- ambiguous")
414
+ renames[pad] = new_pad
415
+ return renames
416
+
417
+
418
+ def _derive_pin_macro_renames(
419
+ original_pins: list[Any], sku: str, source_preset: str, metadata_root: Path,
420
+ ) -> dict[str, str]:
421
+ """Companion to `_derive_pin_renames`: re-derives a `pins:` entry's
422
+ `macro:` field (`EVK_PIN_ENCODER_SW` -> `XEVK_PIN_ENCODER_SW`)
423
+ alongside whichever pad it renames. Needed because
424
+ `alp_orchestrate.loader._validate_topology_cores`'s `pins:` cross-
425
+ check hard-errors when a declared `macro:` doesn't match the
426
+ resolved board's OWN macro for the (possibly re-derived) pad, not
427
+ only on an unrecognised `e1m:` pad (verified: re-deriving `e1m:`
428
+ alone against `peripheral`/E1M-V2N101 still failed `--emit
429
+ zephyr-conf` with `pins[0].macro: EVK_PIN_ENCODER_SW does not
430
+ match the resolved board 'E1M-X-EVK's macros for pad
431
+ E1M_X_GPIO_IO28: ['XEVK_PIN_ENCODER_SW']`). A bare-string `pins:`
432
+ entry (no `macro:` at all) contributes nothing here -- there's no
433
+ macro to keep in sync. Same passthrough/ambiguity-collision
434
+ philosophy as `_derive_pin_renames` (whose pad-rename result this
435
+ always agrees with -- both resolve the SAME target entry via
436
+ `_resolve_pin_target`, just read a different column off it)."""
437
+ renames: dict[str, str] = {}
438
+ for item in original_pins:
439
+ if not isinstance(item, dict):
440
+ continue
441
+ old_macro = item.get("macro")
442
+ if not isinstance(old_macro, str):
443
+ continue
444
+ target = _resolve_pin_target(item, sku, source_preset, metadata_root)
445
+ if target is None:
446
+ continue
447
+ new_macro = target.get("macro")
448
+ if not isinstance(new_macro, str):
449
+ raise TemplateError(
450
+ f"metadata/boards/{_default_preset_for_sku(sku, metadata_root)}"
451
+ f".yaml route for pins: entry {item!r} has no `macro:`")
452
+ if new_macro == old_macro:
453
+ continue
454
+ if old_macro in renames and renames[old_macro] != new_macro:
455
+ raise TemplateError(
456
+ f"macro {old_macro!r} re-derives to two different targets "
457
+ f"({renames[old_macro]!r} and {new_macro!r}) across "
458
+ f"`pins:` entries for sku {sku!r} -- ambiguous")
459
+ renames[old_macro] = new_macro
460
+ return renames
461
+
462
+
463
+ def _derive_pin_doc_renames(
464
+ original_pins: list[Any], sku: str, source_preset: str, metadata_root: Path,
465
+ ) -> dict[str, str | None]:
466
+ """Companion to `_derive_pin_renames`: re-derives a `pins:`
467
+ entry's `doc:` field to the TARGET route's own `doc:` (issue #876
468
+ review MAJOR 2) -- a renamed pin's `doc:` otherwise keeps
469
+ describing the SOURCE board's physical pad/electricals (e.g.
470
+ e1m-evk's encoder-switch doc names a PEC12R-4222F-S0024 debounce
471
+ network; e1m-x-evk's own doc for the same role describes a
472
+ different part with RC debounce), which is actively wrong prose
473
+ once the pad itself has changed -- the same "copy the target's
474
+ own doc" behaviour scripts/gen_portability_matrix.py's
475
+ `_remap_pins` already applies for its own (unrelated) board-preset
476
+ swap path.
477
+
478
+ A value of `None` in the returned map means DROP the `doc:` field
479
+ entirely -- the target route has no `doc:` of its own, and the
480
+ loader already falls back to the resolved board's own `doc:` in
481
+ that case (metadata/schemas/board.schema.json), so dropping it is
482
+ safe, not a silent content gap. An entry without its own `doc:`
483
+ at all contributes nothing (nothing to re-derive).
484
+
485
+ Same ambiguity-collision philosophy as `_derive_pin_renames` and
486
+ `_derive_pin_macro_renames` (issue #1394): a `doc:` string two
487
+ `pins:` entries legitimately SHARE -- one sentence describing a
488
+ debounce network, a bus, or a connector common to both pads --
489
+ keys ONE entry in the flat map `_substitute_board_yaml_pin_docs`
490
+ applies across the whole file, so two entries re-deriving it to
491
+ two different targets must fail loudly instead of silently
492
+ keeping whichever resolution ran last (i.e. whichever `pins:`
493
+ ordering the source file happened to use). `None` participates in
494
+ that check on both sides: "rename it" and "drop it" are
495
+ contradictory instructions for one key, and so are "keep it" (a
496
+ target `doc:` byte-identical to `old_doc`, which contributes no
497
+ map entry) and "drop it" -- the latter pair being the one that
498
+ loses documentation from a pin whose own re-derived `doc:` was
499
+ perfectly good. Hence the separate `resolved` map: it records
500
+ EVERY entry's resolution, including the keep-it ones `renames`
501
+ deliberately omits."""
502
+ renames: dict[str, str | None] = {}
503
+ resolved: dict[str, str | None] = {}
504
+ for item in original_pins:
505
+ if not isinstance(item, dict):
506
+ continue
507
+ old_doc = item.get("doc")
508
+ if not isinstance(old_doc, str):
509
+ continue
510
+ target = _resolve_pin_target(item, sku, source_preset, metadata_root)
511
+ if target is None:
512
+ continue
513
+ new_doc = target.get("doc")
514
+ new = new_doc if isinstance(new_doc, str) else None
515
+ if old_doc in resolved and resolved[old_doc] != new:
516
+ raise TemplateError(
517
+ f"doc {old_doc!r} re-derives to two different targets "
518
+ f"({resolved[old_doc]!r} and {new!r}) across `pins:` "
519
+ f"entries for sku {sku!r} -- ambiguous")
520
+ resolved[old_doc] = new
521
+ if new != old_doc:
522
+ renames[old_doc] = new
523
+ return renames
524
+
525
+
526
+ def _core_board(sku: str, core_id: str | None, metadata_root: Path) -> str | None:
527
+ """`metadata/e1m_modules/<sku>.yaml` `topology.<core_id>.board` --
528
+ the qualified Zephyr board id (`<board>/<soc>/<cpucluster>`) `west
529
+ build -b` needs. `None` for a missing/off/a-class core (no Zephyr
530
+ target) so callers can skip the README board-target rewrite
531
+ cleanly instead of guessing."""
532
+ if not core_id:
533
+ return None
534
+ topology = _topology_for_sku(sku, metadata_root)
535
+ return (topology.get(core_id) or {}).get("board")