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,1037 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`--emit scaffold`: a NEW project's files, rendered from the SDK's catalog.
3
+
4
+ RELOCATED (was alp-sdk `scripts/alp_template.py`). Only the `--emit scaffold`
5
+ path came across -- `render_to_envelope()` and everything it reaches. What stayed
6
+ behind stayed because nothing in `tan` reaches it and a second copy would be a
7
+ second source of truth:
8
+
9
+ * `render()` / `RenderPlan` / `DestinationNotEmptyError` -- the disk-writing
10
+ front door (`alp_template.py render`, and `alp generate`'s `sku=` form). The
11
+ one line of its `plan()` this path actually uses is the FILE ORDER, inlined
12
+ below as `_ordered_files`.
13
+ * `validate()` / `ValidateResult` / `_count_passed` -- the twister self-test that
14
+ renders a template into a temp dir and builds it. An SDK CI gate, not a
15
+ customer path.
16
+ * `default_sku()` and the `argparse` CLI -- callers that stayed in alp-sdk.
17
+
18
+ **This is NOT `tan/core/scaffold.py`, and the two must not be merged.** That
19
+ module serves `tan init` from a VENDORED capture under `tan/templates/vendored/`
20
+ and consults no SDK checkout at all (invariant I-32), deliberately -- see its own
21
+ docstring on why re-deriving the SDK's `_scaffold_cmakelists()` rewrite in tan
22
+ would be a silent `ALP_SDK_ROOT`-guess bug. This module is the OTHER path: it
23
+ runs behind `tan.planner_cli`, which has already bound a real SDK root, so it
24
+ reads that checkout's LIVE `metadata/templates/catalog-v1.json` and the canonical
25
+ example the record names, exactly as `scripts/alp_project.py` did. Two paths, two
26
+ purposes: `tan init` must keep working with no SDK anywhere; `--emit scaffold`
27
+ exists to be byte-identical to the SDK's own front door, which no vendored
28
+ snapshot can promise for a template x SKU pair nobody captured.
29
+
30
+ Two relocation changes, no behavioural one:
31
+
32
+ * **`REPO` / `METADATA_ROOT` are the BOUND SDK checkout** (`paths.py`), not a
33
+ `__file__` walk. The walk landed on the SDK because the file lived there;
34
+ inside `tan` it would land on the `tan` package and every `metadata/**` and
35
+ `examples/**` path would be wrong.
36
+ * **`plan()` collapsed to `_ordered_files`.** `render_to_envelope` called
37
+ `plan()` for one value -- `tuple(sorted(record["files"]["user_owned"]))` -- and
38
+ re-validated the params the very next line already validates. The rest of
39
+ `plan()` served `render()`, which did not come across.
40
+
41
+ Docstrings below still name `render()`, `validate()`, `plan()`/`RenderPlan` and
42
+ `alp_template.py render`: those are alp-sdk's own paths, and the sentences are
43
+ true of the file this moved from. They are kept verbatim so the move stays
44
+ diffable against it.
45
+
46
+ SPLIT (tan-cli#1142): this module was 2206 lines against `MODULE_CAP = 800`.
47
+ `template_pins.py` (SoM/board-metadata reads and rename derivation) and
48
+ `template_rewrite.py` (board.yaml/CMakeLists.txt/README.md text rewrites) now
49
+ carry roughly half the body each; this module keeps the catalog/parameter
50
+ half plus `render_to_envelope`/`emit_scaffold` themselves, since those are
51
+ what `cli.py` imports and where the hand-port audit is densest. All three
52
+ files are the SAME `HAND_PORT_SOURCES` entry for `scripts/alp_template.py` --
53
+ see `template_pins.py`'s own module docstring for why that split is allowed
54
+ (`MIRRORED_PREFIX` bars a `PINNED_HASHES` module's shape from diverging from a
55
+ moving upstream file it is 3-way-merged against; it says nothing about a
56
+ `HAND_PORT_SOURCES` one, which upstream never merges into) and for the load-
57
+ bearing detail of how the two new modules import `TemplateError` back from
58
+ here without a real import cycle.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import json
64
+ from pathlib import Path
65
+ from typing import Any
66
+
67
+ from tan.core.document_guards import SHAPE_NOUN, DocumentGuards
68
+
69
+ from .paths import METADATA_ROOT, REPO
70
+
71
+ __all__ = [
72
+ "ParameterError",
73
+ "SkuNotSupportedError",
74
+ "TemplateError",
75
+ "TemplateNotFoundError",
76
+ "emit_scaffold",
77
+ "render_to_envelope",
78
+ ]
79
+
80
+ CATALOG = REPO / "metadata" / "templates" / "catalog-v1.json"
81
+
82
+
83
+ def _ordered_files(
84
+ record: dict[str, Any], *, doc: Any, field: str,
85
+ ) -> tuple[str, ...]:
86
+ """The envelope's file ORDER: the record's own `files.user_owned` list,
87
+ sorted. Verbatim from `plan()`, which did not otherwise come across (see the
88
+ module docstring). `files.generated` is never in it -- those artefacts are
89
+ emitted later, at build time, by `tan build`.
90
+
91
+ tan-cli#1077: the double subscript was bare. `KeyError: 'files'` on a
92
+ record missing it, `KeyError: 'user_owned'` one level in, and
93
+ `TypeError: '<' not supported between instances of 'int' and 'str'`
94
+ out of `sorted()` on a list whose entries are not the strings the
95
+ schema declares. All three keys are `required` in
96
+ `template-catalog-v1.schema.json` (`items: {"type": "string"}` for the
97
+ list), so naming them is never stricter than that schema."""
98
+ files = _require_key(record, "files", dict, doc=doc, field=field)
99
+ return tuple(sorted(
100
+ _require_field(name, str, doc=doc,
101
+ field=f"{field}.files.user_owned[{i}]")
102
+ for i, name in enumerate(_require_key(
103
+ files, "user_owned", list, doc=doc, field=f"{field}.files"))))
104
+
105
+
106
+ class TemplateError(Exception):
107
+ """Base for every error alp_template raises -- never a bare
108
+ KeyError/ValueError escapes to a caller."""
109
+
110
+
111
+ class TemplateNotFoundError(TemplateError):
112
+ """No record with this id in the catalog."""
113
+
114
+
115
+ class ParameterError(TemplateError):
116
+ """An unknown parameter name, a type mismatch, or a constraint
117
+ violation (enum / minimum / maximum)."""
118
+
119
+
120
+ class SkuNotSupportedError(TemplateError):
121
+ """`render_to_envelope`'s --sku is not in the record's declared
122
+ `supported.som_skus` -- a hard error, never a best-effort render."""
123
+
124
+
125
+ class PathEscapeError(TemplateError):
126
+ """A catalog-declared `files.user_owned` path resolves outside its
127
+ example/destination root (alp-sdk#1126). The schema itself puts no
128
+ shape constraint on these entries (plain strings, no pattern), so
129
+ this is enforced at the point of use, not by schema validation
130
+ alone -- containment is checked on the RESOLVED path (symlinks
131
+ followed), not by pattern-matching for `..`."""
132
+
133
+
134
+ class AmbiguousCoresError(TemplateError):
135
+ """`find_template_by_cores()`'s `cores` topology matches more than
136
+ one catalog record -- naming the candidates rather than guessing
137
+ which one the caller meant (use `--template` to disambiguate)."""
138
+
139
+
140
+ def load_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
141
+ """Parse `metadata/templates/catalog-v1.json` -- the FOURTH document
142
+ this module reads, and its only JSON one (tan-cli#1077).
143
+
144
+ The three YAML documents already go through `_require_mapping_doc`;
145
+ this decode did not. A catalog that is legal JSON but not an object (a
146
+ bare list, a bare scalar) reached every caller's `doc.get("templates",
147
+ ...)` as a raw `AttributeError`, and a catalog that is not legal JSON
148
+ at all escaped as a raw `json.JSONDecodeError` -- and `cli._emit_
149
+ scaffold` catches `TemplateError` and nothing else, so both reached a
150
+ CLI user as a traceback. That is the defect this whole family
151
+ (tan-cli#1025 -> #1034 -> #1037/#1048 -> #1052 -> this) exists to
152
+ close, one document at a time.
153
+
154
+ The `noun` is "a JSON object", not the register's shared default "a
155
+ YAML mapping": same helper, same message shape, one word that is true
156
+ of THIS document. The three YAML callers keep their default and their
157
+ byte-identical message.
158
+
159
+ The ABSENT-document half is curated here too (tan-cli#1077 review). The
160
+ first cut deferred it as "equally true of all four documents this
161
+ module reads"; measured, it was not -- `_load_som_doc` and
162
+ `_board_route_entries` carried an `is_file()` check and `_docs_ref` an
163
+ `except OSError`, so three of the module's five reads were ALREADY
164
+ handled and only this one and `render_to_envelope`'s example
165
+ `board.yaml` were bare. A false symmetry claim in a docstring is the
166
+ same defect class this round exists to close, so both are guarded and
167
+ the claim is gone. `except OSError`, not a pre-flight `is_file()`: a
168
+ present-but-unreadable path (a directory, a permissions error) is named
169
+ too, not only a missing one.
170
+
171
+ tan-cli#1133 finished that and corrected the COUNT: this module makes
172
+ SIX filesystem reads, not five -- the four documents (this catalog, the
173
+ SoM preset, the board metadata, the example `board.yaml`), `_docs_ref`'s
174
+ `sdk_version.yaml`, and `_rendered_bytes`' per-file `read_bytes()`,
175
+ which no round of this family had looked at and which was raw on three
176
+ separate errnos (PR #1160 review, MAJOR 1). All six are curated now and
177
+ no `is_file()` pre-flight is left. Re-derive by grepping
178
+ `read_text|read_bytes|safe_load|json.loads` here rather than inheriting:
179
+ the version of this claim one revision ago was off by one, in the
180
+ direction that hid a live defect.
181
+
182
+ tan-cli#1084: the read itself is `DocumentGuards.read_catalog_document`
183
+ now -- the SAME body, moved to `tan/core/document_guards.py` so
184
+ `tan/core/example_catalog.py`'s second implementation of this read
185
+ produces byte-identical messages instead of the raw
186
+ `FileNotFoundError`/`JSONDecodeError`/`AttributeError` it used to.
187
+ """
188
+ return _GUARDS.read_catalog_document(catalog_path or CATALOG)
189
+
190
+
191
+ def find_template(
192
+ doc: dict[str, Any], template_id: str, *, path: Any = CATALOG,
193
+ ) -> dict[str, Any]:
194
+ """The catalog record whose `id:` is @template_id.
195
+
196
+ tan-cli#1077: `rec["id"]` was bare at BOTH sites -- the match scan and
197
+ the not-found message -- so a `templates:` entry with no `id:` (or one
198
+ that is not a mapping at all) raised `KeyError: 'id'` / `TypeError`
199
+ instead of a curated error naming the catalog, the record and the
200
+ type. @path is a MESSAGE LABEL only, never read.
201
+
202
+ Its `CATALOG` default is the bound checkout's own catalog -- exactly
203
+ the file `load_catalog()` reads when no caller overrode IT, so the two
204
+ defaults agree and the label cannot lie for the one caller that omits
205
+ it (`tan/planner/cli.py:182`, whose `load_catalog()` is also
206
+ default-pathed). It CAN lie for a caller that passes a custom
207
+ `catalog_path` to `load_catalog` and then omits `path` here; there is
208
+ no such caller today (`render_to_envelope` threads the resolved path
209
+ through, and every test that asserts on the file name passes it
210
+ explicitly), and making it required would mean editing `cli.py` and
211
+ the pre-existing `test_find_template_by_cores.py` fixtures for a label
212
+ (tan-cli#1077 review nit -- recorded rather than silently accepted).
213
+
214
+ Every id is resolved up front, as the not-found path always did (and
215
+ as the match scan did for every record up to the match). A record
216
+ malformed AFTER the requested one therefore reds where it used to be
217
+ skipped: a new refusal, of a document the schema never accepted (`id`
218
+ is `required` on every record), asserted as its own test rather than
219
+ folded in.
220
+ """
221
+ records = _catalog_templates(doc, path=path)
222
+ ids = [_require_key(rec, "id", str, doc=path, field=f"templates[{i}]")
223
+ for i, rec in enumerate(records)]
224
+ for rec, rec_id in zip(records, ids):
225
+ if rec_id == template_id:
226
+ return rec
227
+ known = ", ".join(sorted(ids))
228
+ raise TemplateNotFoundError(
229
+ f"no template {template_id!r} in catalog (known: {known})")
230
+
231
+
232
+ def find_template_by_cores(
233
+ doc: dict[str, Any], cores: dict[str, str], *, path: Any = CATALOG,
234
+ ) -> dict[str, Any]:
235
+ """Select the catalog record whose `cores:` topology (core id ->
236
+ os) is EXACTLY `cores` -- alp-sdk#1652's `--cores` scaffold input.
237
+
238
+ RELOCATED verbatim from alp-sdk's `scripts/alp_template.py` (the
239
+ HAND_PORT_HASHES entry for this file). This is a SELECTOR over the
240
+ catalog's existing templates, not a generic skeleton renderer: an
241
+ IDE wizard names the core/OS topology it wants (e.g. `{"m55_hp":
242
+ "zephyr", "m55_he": "zephyr"}`) instead of naming a template id,
243
+ and gets back whichever already-gated example matches -- never an
244
+ arbitrary, never-built combination. See the issue's recorded
245
+ decision: the scaffold's value to a customer is that the generated
246
+ app builds on their SoM, which only holds for a topology this SDK
247
+ already ships and twister-gates.
248
+
249
+ No exact match -> TemplateNotFoundError naming the topologies that
250
+ ARE on offer. More than one exact match -> AmbiguousCoresError
251
+ naming the candidate ids (use --template to disambiguate; this can
252
+ happen when two templates share a core/OS shape but differ in
253
+ what they actually do, e.g. an RPMsg demo vs a compute-offload
254
+ demo on the same SoM).
255
+
256
+ `tan init`'s customer-facing selector (`--topology`) does NOT call
257
+ this function -- it runs with no SDK checkout bound at module-import
258
+ time (invariant I-32) and cannot import `tan.planner` for that
259
+ reason (see `tan/core/example_catalog.py`'s own docstring on the
260
+ same constraint), so it carries a small standalone re-implementation
261
+ instead. This copy is the one `tan.planner_cli --emit scaffold
262
+ --cores` (the developer/parity entry that mirrors alp-sdk's own argv
263
+ 1:1) actually calls, and the one `HAND_PORT_HASHES` audits against
264
+ alp-sdk's original.
265
+
266
+ @path is the same message-label-only keyword `find_template` takes,
267
+ with the same `CATALOG` default and the same caveat -- see its
268
+ docstring.
269
+
270
+ tan-cli#1077: `_topology`'s `{c["id"]: c["os"] ...}` and the ambiguous
271
+ branch's `rec["id"]` were bare subscripts on catalog-sourced mappings,
272
+ and all three `doc.get("templates", [])` iterations were unguarded.
273
+ `id`/`os` are `required` on every `cores[]` entry and `id` on every
274
+ record, so requiring them is not stricter than the schema. Each
275
+ topology is computed ONCE now (it used to be recomputed per record per
276
+ branch) and the ids of the MATCHES are resolved before the >1 test, so
277
+ the single-match record's `id:` is checked too -- that is what makes
278
+ `cli._emit_scaffold`'s own `record["id"]` (`planner/cli.py:186`) safe,
279
+ the one site of this defect that lives outside this module.
280
+ """
281
+ def _topology(rec: Any, index: int) -> dict[str, str]:
282
+ field = f"templates[{index}]"
283
+ _require_field(rec, dict, doc=path, field=field)
284
+ entries = _require_field(rec.get("cores", []), list,
285
+ doc=path, field=f"{field}.cores")
286
+ return {
287
+ _require_key(core, "id", str, doc=path,
288
+ field=f"{field}.cores[{j}]"):
289
+ _require_key(core, "os", str, doc=path,
290
+ field=f"{field}.cores[{j}]")
291
+ for j, core in enumerate(entries)}
292
+
293
+ indexed = list(enumerate(_catalog_templates(doc, path=path)))
294
+ topologies = {index: _topology(rec, index) for index, rec in indexed}
295
+ matches = [(index, rec) for index, rec in indexed
296
+ if topologies[index] == cores]
297
+ if not matches:
298
+ known = sorted(
299
+ {tuple(sorted(topo.items())) for topo in topologies.values()})
300
+ raise TemplateNotFoundError(
301
+ f"no template with cores topology {cores!r} in catalog "
302
+ f"(known topologies: {known})")
303
+ ids = sorted(
304
+ _require_key(rec, "id", str, doc=path, field=f"templates[{index}]")
305
+ for index, rec in matches)
306
+ if len(matches) > 1:
307
+ raise AmbiguousCoresError(
308
+ f"cores topology {cores!r} matches multiple templates "
309
+ f"{ids} -- use --template to disambiguate")
310
+ return matches[0][1]
311
+
312
+
313
+ def _coerce(spec: dict[str, Any], raw: Any) -> Any:
314
+ """Coerce a CLI-style string override to the parameter's declared
315
+ type. Values already of the right type (e.g. an untouched default,
316
+ or a native value a Python caller passed directly) pass through.
317
+
318
+ `spec["type"]` / `spec["name"]` stay BARE subscripts on purpose
319
+ (tan-cli#1077): this function and `_check_constraints` are reached
320
+ only from `_resolve_params` (`:346` and `:347`, verified by the
321
+ tan-cli#1077 review), and only after `_record_parameters` has required
322
+ both keys on every spec. Guarding them a second time here would be a
323
+ second register for the same fact.
324
+
325
+ That reasoning covers the SHAPE of `_check_constraints`'s
326
+ `spec.get("constraints")` and its `constraints["enum"]` /
327
+ `["minimum"]` / `["maximum"]` reads -- and only their shape, and only
328
+ since `_require_constraints` joined `_record_parameters`. Before that,
329
+ this docstring's claim was written while a sixth unguarded subscript
330
+ sat one function over (tan-cli#1077 review, MAJOR 1).
331
+
332
+ It does NOT cover what `_check_constraints` then DOES with them: a
333
+ schema-VALID `type: string` (or `enum`) parameter carrying
334
+ `constraints.minimum`/`maximum` is legal per `$defs/parameter`, and
335
+ `_require_constraints` guarantees the `int` that would make a bare
336
+ `"a" < 5` raise a `TypeError`. tan-cli#1087 closed that: `_check_
337
+ constraints` now refuses `minimum`/`maximum` on any non-`integer`
338
+ `type` with a curated `ParameterError` before it ever compares, so the
339
+ bare `TypeError` this paragraph used to describe as latent no longer
340
+ happens -- do not read it as still open."""
341
+ if not isinstance(raw, str):
342
+ return raw
343
+ ptype = spec["type"]
344
+ if ptype == "integer":
345
+ try:
346
+ return int(raw)
347
+ except ValueError as exc:
348
+ raise ParameterError(
349
+ f"{spec['name']}: {raw!r} is not an integer") from exc
350
+ if ptype == "boolean":
351
+ if raw.lower() in ("1", "true", "yes"):
352
+ return True
353
+ if raw.lower() in ("0", "false", "no"):
354
+ return False
355
+ raise ParameterError(f"{spec['name']}: {raw!r} is not a boolean")
356
+ return raw # string / enum stay strings
357
+
358
+
359
+ def _check_constraints(template_id: str, spec: dict[str, Any], value: Any) -> None:
360
+ """Enforce `spec["constraints"]` against the coerced @value.
361
+
362
+ tan-cli#1087: `minimum`/`maximum` are only well-typed against `type:
363
+ integer` -- `$defs/parameter.type` is exactly `string`/`integer`/
364
+ `boolean`/`enum`, none of the other three compare against an `int`
365
+ bound, and the schema does not cross-reference `type` against
366
+ `constraints` at all. `boolean` is refused too, on purpose, even though
367
+ `bool < int` never raises -- a bound that cannot crash still is not one
368
+ that means anything on a boolean knob. A schema addition of a fifth
369
+ numeric `type` would need a matching line here.
370
+
371
+ DIVERGES FROM alp-sdk on this input class, deliberately: `scripts/
372
+ alp_template.py`'s own `_check_constraints` has no such guard and still
373
+ raises the bare `TypeError` this closes. alp-sdk#1916 tracks closing the
374
+ gap; until then tan refuses where alp-sdk still crashes.
375
+
376
+ `constraints.enum` is the SAME inapplicability class one line below --
377
+ an `integer` parameter can never satisfy it, since the schema forces
378
+ `enum` items to strings -- and is DELIBERATELY LEFT OPEN here: it fails
379
+ loudly with a value-blaming message rather than crashing, so it stayed
380
+ out of #1087's scope. alp-sdk#1916 tracks it too.
381
+ """
382
+ constraints = spec.get("constraints") or {}
383
+ if "enum" in constraints and value not in constraints["enum"]:
384
+ raise ParameterError(
385
+ f"{template_id}: {spec['name']}={value!r} not in "
386
+ f"{constraints['enum']}")
387
+ for bound in ("minimum", "maximum"):
388
+ if bound in constraints and spec["type"] != "integer":
389
+ raise ParameterError(
390
+ f"{template_id}: {spec['name']}={value!r} is type "
391
+ f"{spec['type']!r}; constraints.{bound} "
392
+ f"({constraints[bound]!r}) only applies "
393
+ f"to type 'integer'")
394
+ if "minimum" in constraints and value < constraints["minimum"]:
395
+ raise ParameterError(
396
+ f"{template_id}: {spec['name']}={value!r} < minimum "
397
+ f"{constraints['minimum']}")
398
+ if "maximum" in constraints and value > constraints["maximum"]:
399
+ raise ParameterError(
400
+ f"{template_id}: {spec['name']}={value!r} > maximum "
401
+ f"{constraints['maximum']}")
402
+
403
+
404
+ def _resolve_params(
405
+ record: dict[str, Any], params: dict[str, Any] | None,
406
+ *, doc: Any, field: str,
407
+ ) -> dict[str, Any]:
408
+ """Resolve every declared parameter to its effective value (override
409
+ or default), rejecting any name the record doesn't declare -- this
410
+ can never invent a knob the catalog doesn't have.
411
+
412
+ tan-cli#1077: `p["name"]`, `spec["default"]` and `record["id"]` (twice)
413
+ were bare subscripts on a catalog-sourced mapping. The spec shapes are
414
+ resolved once by `_record_parameters`, so `_coerce`/`_check_constraints`
415
+ below keep reading `spec["type"]`/`spec["name"]`/`spec["default"]` bare
416
+ -- see `_coerce`'s docstring."""
417
+ specs = _record_parameters(record, doc=doc, field=field)
418
+ declared = {spec["name"]: spec for spec in specs}
419
+ template_id = _require_key(record, "id", str, doc=doc, field=field)
420
+ params = dict(params or {})
421
+ unknown = sorted(set(params) - set(declared))
422
+ if unknown:
423
+ raise ParameterError(
424
+ f"{template_id}: unknown parameter(s) {unknown}; declared: "
425
+ f"{sorted(declared) or '(none)'}")
426
+
427
+ resolved: dict[str, Any] = {}
428
+ for name, spec in declared.items():
429
+ value = _coerce(spec, params.get(name, spec["default"]))
430
+ _check_constraints(template_id, spec, value)
431
+ resolved[name] = value
432
+ return resolved
433
+
434
+
435
+ def _substitutions_for(
436
+ record: dict[str, Any], resolved: dict[str, Any], *, doc: Any, field: str,
437
+ ) -> dict[str, list[tuple[str, str]]]:
438
+ """dest-relative file -> [(literal_to_replace, new_value_str), ...].
439
+
440
+ Reads an opt-in `substitute: {"file": ..., "literal": <optional,
441
+ defaults to str(default)>}` key on a parameter record. No parameter
442
+ the shipped catalog declares today carries this key (the schema
443
+ forbids it -- additionalProperties: false), so this is a no-op for
444
+ every real template; see the module docstring and
445
+ tests/scripts/test_alp_template.py's synthetic-fixture case.
446
+
447
+ tan-cli#1077: `sub["file"]` was a bare subscript and `sub` itself was
448
+ never shape-checked, so a `substitute:` block with no `file:` raised
449
+ `KeyError: 'file'` and a non-mapping one raised `AttributeError` from
450
+ `.get("literal", ...)`. Guarded in the ORIGINAL order -- a spec whose
451
+ override equals its default still returns early, untouched, exactly as
452
+ before, so this adds no refusal to a document that used to render.
453
+ """
454
+ per_file: dict[str, list[tuple[str, str]]] = {}
455
+ for index, spec in enumerate(
456
+ _record_parameters(record, doc=doc, field=field)):
457
+ sub = spec.get("substitute")
458
+ if not sub:
459
+ continue
460
+ sub_field = f"{field}.parameters[{index}].substitute"
461
+ value = resolved[spec["name"]]
462
+ if value == spec["default"]:
463
+ continue # override equals default: nothing to change
464
+ literal = _require_field(sub, dict, doc=doc, field=sub_field).get(
465
+ "literal", str(spec["default"]))
466
+ target = _require_key(sub, "file", str, doc=doc, field=sub_field)
467
+ per_file.setdefault(target, []).append((literal, str(value)))
468
+ return per_file
469
+
470
+
471
+ def _safe_join(root: Path, rel: str, *, what: str) -> Path:
472
+ """Join `rel` onto `root` and require the RESOLVED result stay
473
+ beneath the RESOLVED root (alp-sdk#1126).
474
+
475
+ Resolve-then-contain, not pattern-match-for-`..`: `(root / rel)`
476
+ already neutralises nothing on its own -- pathlib lets an absolute
477
+ `rel` replace `root` outright (`Path("a") / "/etc/passwd" ==
478
+ Path("/etc/passwd")`), and a lexical `..` scan misses a `rel` that
479
+ walks back out through a symlink placed inside `root`. Resolving
480
+ both sides and checking containment catches all three forms
481
+ (traversal, absolute paths, symlink escape) with one check.
482
+
483
+ tan-cli#1133 (PR #1160 review, found while driving `_rendered_bytes`'s
484
+ symlink-loop shape): `resolve()` itself can fail, and it fails
485
+ DIFFERENTLY per interpreter -- the same family as the tan-cli#1127
486
+ `is_file()` trap, one method over. Against a self-referential symlink,
487
+ measured non-root:
488
+
489
+ 3.12.3 RuntimeError("Symlink loop from '<path>'") <- raised
490
+ 3.13.15 returns the path unchanged
491
+ 3.14.7 returns the path unchanged
492
+
493
+ So on 3.12.3 a looped template source file escaped `emit_scaffold` as a
494
+ raw `RuntimeError` -- not even an `OSError`, so no read guard downstream
495
+ could ever have caught it -- while on 3.13/3.14 the same tree fell
496
+ through to the read and was curated there. Both arms are curated now.
497
+ The two messages still DIFFER by interpreter, because the failure
498
+ genuinely happens at different points, and inventing one message for
499
+ both would mean lying about where it broke on one of them; what is
500
+ identical across all three is the CLASS the caller contracts for."""
501
+ try:
502
+ root = root.resolve()
503
+ candidate = (root / rel).resolve()
504
+ except RecursionError:
505
+ # `RecursionError` SUBCLASSES `RuntimeError`, so the clause below
506
+ # would otherwise report a runaway recursion inside `resolve()` as
507
+ # `cannot resolve <what> ...` -- a curated message about the wrong
508
+ # thing, which is its own defect class (PR #1160 review round 2).
509
+ # The measured shape is the plain `RuntimeError("Symlink loop from
510
+ # ...")`; nothing here is a claim about recursion depth.
511
+ raise
512
+ except (OSError, RuntimeError) as exc:
513
+ raise TemplateError(
514
+ f"cannot resolve {what} {rel!r} under {root}: "
515
+ f"{getattr(exc, 'strerror', None) or exc}") from exc
516
+ if not candidate.is_relative_to(root):
517
+ raise PathEscapeError(f"{what} {rel!r} escapes root {root}")
518
+ return candidate
519
+
520
+
521
+ def _rendered_bytes(
522
+ template_id: str,
523
+ record: dict[str, Any],
524
+ files: tuple[str, ...],
525
+ resolved: dict[str, Any],
526
+ base_dir: Path,
527
+ *,
528
+ doc: Any,
529
+ field: str,
530
+ ) -> list[tuple[str, bytes]]:
531
+ """Read + apply every declared-parameter substitution for `files`
532
+ (a RenderPlan.files list), returning [(relpath, bytes), ...] in the
533
+ same order. Shared by render()'s disk-write loop and
534
+ render_to_envelope()'s in-memory capture -- the same bytes a
535
+ customer gets from `alp_template.py render` are what `--emit
536
+ scaffold` hands back as JSON `contents` (see the module docstring).
537
+
538
+ tan-cli#1077: `record["example"]` was bare here and again twice in
539
+ `render_to_envelope`. `example` is `required` in the schema (and
540
+ pattern-constrained to `^examples/<dir>/<dir>$`), so a record without
541
+ it raised `KeyError: 'example'`; a non-string one raised a raw
542
+ `TypeError` from `_safe_join`'s `root / rel`.
543
+
544
+ tan-cli#1133 review (PR #1160 MAJOR 1): the read below was the FOURTH
545
+ absent-`try` site and the busiest of the six (4-7 files per scaffold);
546
+ the substitution branch's `.decode` was a FIFTH, LATENT one (no shipped
547
+ catalog can declare a `substitute:`). Per-cell measurement, and the
548
+ schema evidence for that, in `tests/planner/test_emit_scaffold_
549
+ unreadable_metadata.py`."""
550
+ example = _safe_join(
551
+ base_dir, _require_key(record, "example", str, doc=doc, field=field),
552
+ what="template example directory")
553
+ file_subs = _substitutions_for(record, resolved, doc=doc, field=field)
554
+ out: list[tuple[str, bytes]] = []
555
+ for rel in files:
556
+ path = _safe_join(example, rel, what="template source file")
557
+ data = _require_readable_bytes(path, what="template source file")
558
+ subs = file_subs.get(rel)
559
+ if subs:
560
+ try:
561
+ text = data.decode("utf-8")
562
+ except UnicodeDecodeError as exc:
563
+ raise TemplateError(
564
+ f"{template_id}: {rel} is not valid UTF-8 text, cannot "
565
+ f"have substitutions applied ({exc})") from exc
566
+ for literal, value in subs:
567
+ if literal not in text:
568
+ raise ParameterError(
569
+ f"{template_id}: substitution literal {literal!r} "
570
+ f"not found in {rel}")
571
+ text = text.replace(literal, value)
572
+ data = text.encode("utf-8")
573
+ out.append((rel, data))
574
+ return out
575
+
576
+
577
+ # ---------------------------------------------------------------------
578
+ # --emit scaffold (issue #864): in-memory, SKU-parameterised capture
579
+ # ---------------------------------------------------------------------
580
+
581
+ #: The malformed-document register, bound to THIS module's error class.
582
+ #:
583
+ #: tan-cli#1084 MOVED the register itself to `tan/core/document_guards.py`
584
+ #: (definitions, message shapes, schema-strictness claim and the recorded
585
+ #: falsy-value asymmetry all live in that module's docstring). It did not
586
+ #: change a single one of them, and it did not change a call site: the names
587
+ #: below are the SAME objects this module has used since tan-cli#1073/#1077,
588
+ #: so every `_require_field(...)` / `_require_key(...)` / `_catalog_templates
589
+ #: (...)` call in this file is byte-identical to what those PRs landed.
590
+ #:
591
+ #: The move exists because `tan/core/example_catalog.py` is a DELIBERATE
592
+ #: second implementation of the catalog read (`tan.planner` binds its SDK root
593
+ #: at module-import time, so `tan init`'s SDK-free path cannot import this
594
+ #: module at all -- see that file's own docstring) and, after tan-cli#1077,
595
+ #: this module refused a malformed catalog with a curated error while that one
596
+ #: still crashed raw. One register, two callers, one exception class each:
597
+ #: `cli._emit_scaffold` catches `TemplateError` and nothing else, so the class
598
+ #: is a constructor argument rather than a fixed type.
599
+ #:
600
+ #: `_require_constraints` below did NOT move -- it guards `$defs/parameter`'s
601
+ #: bounds, which only this module's parameter resolution reads, so it has no
602
+ #: second caller to prove it shared. `require_readable_text` (tan-cli#1085)
603
+ #: is the file-read half `render_to_envelope`'s example `board.yaml` read
604
+ #: used to run by hand -- now the same definition `read_catalog_document`
605
+ #: itself calls.
606
+ _GUARDS = DocumentGuards(TemplateError)
607
+
608
+ _SHAPE_NOUN = SHAPE_NOUN
609
+ _require_mapping_doc = _GUARDS.require_mapping_doc
610
+ _require_field = _GUARDS.require_field
611
+ _require_key = _GUARDS.require_key
612
+ _require_readable_text = _GUARDS.require_readable_text
613
+ _catalog_templates = _GUARDS.catalog_templates
614
+ #: tan-cli#1133's four. The first cut of that fix defined the two YAML ones
615
+ #: as module-level functions HERE, on the register's stated membership bar
616
+ #: ("a shape RUN BY more than one consumer module"): `example_catalog.py`,
617
+ #: the register's second consumer, reads JSON only. PR #1160's review asked
618
+ #: the question the other way round and it is the better question --
619
+ #: `read_catalog_document` is already read + parse + mapping-check for JSON,
620
+ #: so keeping the identical composite for YAML in a CONSUMER split one
621
+ #: question across two homes for no reason a reader could reconstruct. The
622
+ #: import-closure half of the original argument was also simply wrong: seven
623
+ #: `tan/core/**` modules defer `import yaml` into a function body for exactly
624
+ #: this purpose. Moved, and `template.py` is 71 lines lighter for it -- at
625
+ #: the time argued as mattering MORE here than in an ordinary module,
626
+ #: because `_module_size_budget_core.MIRRORED_PREFIX`'s comment was read as
627
+ #: barring a split of this file. WRONG: that comment describes
628
+ #: `PINNED_HASHES` modules (3-way-merged against a moving upstream file,
629
+ #: where a shape change would conflict on every future upstream hunk), and
630
+ #: this module is `HAND_PORT_SOURCES` instead -- see `template_pins.py`'s
631
+ #: module docstring for the correction and the actual split (tan-cli#1142).
632
+ _require_readable_bytes = _GUARDS.require_readable_bytes
633
+ _parse_yaml_mapping = _GUARDS.require_yaml_mapping_doc
634
+ _read_yaml_mapping = _GUARDS.read_yaml_mapping
635
+
636
+
637
+ def _record_parameters(record: Any, *, doc: Any, field: str) -> list[Any]:
638
+ """The record's `parameters:` list, every spec shape-checked once.
639
+
640
+ tan-cli#1077: `{p["name"]: p for p in record.get("parameters", [])}`
641
+ (`:229`) and `spec["default"]` (`:239`, `:263`, `:265`) were bare, so
642
+ a spec missing either raised `KeyError`, and a non-list `parameters:`
643
+ raised a raw `TypeError`. `name`/`type`/`description`/`default` are
644
+ all `required` in the schema's `$defs/parameter`; `default` carries no
645
+ declared type there, so only its PRESENCE is required here, and
646
+ `description` is not read by this module at all and is not required
647
+ here either -- guarding a key nobody subscripts would be exactly the
648
+ stricter-than-the-schema this issue rules out.
649
+
650
+ `constraints:` is checked here too, via `_require_constraints` --
651
+ tan-cli#1077 review MAJOR 1, whose six measured failures (five raw
652
+ `TypeError`s and one SILENT dropped bound) that helper's own docstring
653
+ records.
654
+
655
+ Called by BOTH `_resolve_params` and `_substitutions_for`, which each
656
+ walked the same list bare; the check is idempotent, so the second
657
+ call re-proves what the first did rather than trusting a caller.
658
+ """
659
+ _require_field(record, dict, doc=doc, field=field)
660
+ specs = _require_field(record.get("parameters", []), list,
661
+ doc=doc, field=f"{field}.parameters")
662
+ for index, spec in enumerate(specs):
663
+ spec_field = f"{field}.parameters[{index}]"
664
+ _require_key(spec, "name", str, doc=doc, field=spec_field)
665
+ _require_key(spec, "type", str, doc=doc, field=spec_field)
666
+ _require_key(spec, "default", doc=doc, field=spec_field)
667
+ _require_constraints(spec, doc=doc, field=spec_field)
668
+ return specs
669
+
670
+
671
+ def _require_constraints(spec: dict[str, Any], *, doc: Any, field: str) -> None:
672
+ """`constraints:` and the three bounds `_check_constraints` reads.
673
+
674
+ OPTIONAL in the schema, so an absent block is legal and this returns
675
+ without a word; only a PRESENT one is shape-checked, against
676
+ `$defs/parameter`'s own `constraints` object (`enum` an array,
677
+ `minimum`/`maximum` integers).
678
+
679
+ tan-cli#1077 review MAJOR 1: `_check_constraints` read
680
+ `spec.get("constraints") or {}` and then membership-tested and
681
+ subscripted it, INSIDE a function the first sweep table declared
682
+ cleared. Re-driven verbatim on the tree this PR opened with:
683
+
684
+ constraints: 3 TypeError: argument of type 'int' is
685
+ not iterable :307
686
+ constraints: ['enum'] TypeError: list indices must be
687
+ integers or slices, not str :307
688
+ constraints: ['minimum'] same :311
689
+ constraints: ['maximum'] same :315
690
+ constraints: {enum: 3} TypeError: argument of type 'int' is
691
+ not iterable :307
692
+ constraints: {minimum: 'a'} TypeError: '<' not supported between
693
+ instances of 'int' and 'str' :311
694
+ constraints: 'abc' RENDERS -- every bound DROPPED
695
+
696
+ The last row is the serious one, and the same shape as the
697
+ `pins: 'E1M_GPIO_IO4'` character-iteration bug tan-cli#1052 found on
698
+ this file: no exception, wrong behaviour. `"enum" in "abc"` is a
699
+ SUBSTRING test, so every bound evaluated False and an out-of-range
700
+ override was ACCEPTED. The behaviour even depended on the spelling of
701
+ the junk -- `constraints: 'an enum'` DOES contain the substring and
702
+ took the `TypeError` branch instead.
703
+ """
704
+ raw = spec.get("constraints")
705
+ if raw is None:
706
+ return
707
+ cons_field = f"{field}.constraints"
708
+ _require_field(raw, dict, doc=doc, field=cons_field)
709
+ if "enum" in raw:
710
+ _require_field(raw["enum"], list, doc=doc, field=f"{cons_field}.enum")
711
+ for bound in ("minimum", "maximum"):
712
+ if bound in raw:
713
+ _require_field(raw[bound], int, doc=doc,
714
+ field=f"{cons_field}.{bound}")
715
+
716
+
717
+ #: `template_pins.py` / `template_rewrite.py` (tan-cli#1142 split -- see the
718
+ #: module docstring). Deliberately imported here, AFTER the exception classes
719
+ #: and the `_GUARDS` block above rather than in this module's top import
720
+ #: block: each of those two modules imports `TemplateError` (and
721
+ #: `template_pins.py` also imports `_require_field`/`_read_yaml_mapping`) back
722
+ #: FROM `.template`, so this is a real import cycle at the module-object
723
+ #: level. It resolves because Python does not re-execute an already-importing
724
+ #: module -- it looks up the requested names on the (partially built) module
725
+ #: object -- and by the time THIS line runs, `TemplateError` and the `_GUARDS`
726
+ #: bindings are already set as attributes on `tan.planner.template`. Moving
727
+ #: this import above the `_GUARDS` block (or moving that block below this
728
+ #: import) breaks the cycle the other way and fails at import time. The names
729
+ #: below are also `template.py`'s re-export surface for them: `import
730
+ #: tan.planner.template as m; m._load_som_doc` (and ~a dozen siblings) is how
731
+ #: `python/tests/` reaches these across the split, so removing one of these
732
+ #: bindings without checking its test callers first reproduces exactly the
733
+ #: silent-coverage-gap shape tan-cli#279/#778 exist to catch, one module over.
734
+ from .template_pins import (
735
+ _alias_for_pin,
736
+ _board_alias_to_entry,
737
+ _board_route_entries,
738
+ _core_board,
739
+ _default_preset_for_sku,
740
+ _derive_core_renames,
741
+ _derive_pin_doc_renames,
742
+ _derive_pin_macro_renames,
743
+ _derive_pin_renames,
744
+ _load_som_doc,
745
+ _pin_pad_and_macro,
746
+ _resolve_pin_target,
747
+ _topology_for_sku,
748
+ )
749
+ from .template_rewrite import (
750
+ _docs_ref,
751
+ _scaffold_bare_repo_paths,
752
+ _scaffold_cmakelists,
753
+ _scaffold_readme,
754
+ _strip_stale_core_prose,
755
+ _substitute_board_yaml_core,
756
+ _substitute_board_yaml_pin_docs,
757
+ _substitute_board_yaml_pin_macros,
758
+ _substitute_board_yaml_pins,
759
+ _substitute_board_yaml_sku,
760
+ _substitute_readme_pins,
761
+ _tag_resolves,
762
+ )
763
+
764
+
765
+ def render_to_envelope(
766
+ template_id: str,
767
+ sku: str,
768
+ params: dict[str, Any] | None = None,
769
+ *,
770
+ catalog_path: Path | None = None,
771
+ base_dir: Path | None = None,
772
+ metadata_root: Path | None = None,
773
+ ) -> list[tuple[str, str]]:
774
+ """Render `template_id` for `sku` entirely in memory: no dest_dir, no
775
+ disk write. Returns `[(path, contents), ...]` in the same sorted
776
+ order `plan()` computes -- the `{path, contents}[]` shape
777
+ `scripts/alp_project.py --emit scaffold` JSON-encodes (issue #864),
778
+ matching the shape `--emit build-plan`'s `configArtefacts` /
779
+ `sharedArtefacts` already use. `testcase.yaml` is never in this
780
+ envelope (dropped from the catalog's `files.user_owned`: SDK CI
781
+ wiring, not a user's project file).
782
+
783
+ `sku` MUST be one of the record's declared `supported.som_skus` --
784
+ SkuNotSupportedError (naming the supported set) otherwise, never a
785
+ silent best-effort render. The rendered `board.yaml`'s `som.sku:`
786
+ and top-level `preset:` are substituted for `sku`'s own default
787
+ board (metadata/e1m_modules/<sku>.yaml `default_board:`). The app
788
+ CORE is re-derived too (`_derive_core_renames`): `board.yaml`'s
789
+ `cores:` key(s) are rewritten
790
+ from the canonical example's own SoM core (e.g. `m55_hp`) to
791
+ `sku`'s own Zephyr-buildable core (e.g. `m33_sm` for E1M-V2N101)
792
+ whenever the canonical core isn't already valid for `sku` -- this
793
+ is the fix for issue #864's follow-up: the shallow `som.sku`-only
794
+ swap emitted a board.yaml `--emit zephyr-conf --core m55_hp` can't
795
+ build against for any cross-SoM-family sku. `prj.conf` is a byte-
796
+ identical passthrough when `sku` already matches the example's own
797
+ default (or shares its core ids); `board.yaml`/`src/*.c`/`src/*.h`
798
+ keep their sku/core/pin substitutions scoped to that case but ALWAYS
799
+ get `_scaffold_bare_repo_paths` (alp-sdk#1855: a bare, non-markdown-
800
+ link `docs/*.md`/`examples/<...>` mention in a comment is wrong for
801
+ a copied-out scaffold regardless of which sku was requested, same
802
+ reasoning as the next sentence). CMakeLists.txt and README.md are
803
+ ALSO scaffold-adapted regardless of `sku` (`_scaffold_cmakelists` /
804
+ `_scaffold_readme`) -- their in-tree `ALP_SDK_ROOT` guess and SDK-
805
+ tree-relative links/paths are wrong for a copied-out scaffold no
806
+ matter which sku was requested.
807
+ """
808
+ catalog = catalog_path or CATALOG
809
+ doc = load_catalog(catalog)
810
+ record = find_template(doc, template_id, path=catalog)
811
+ # tan-cli#1077: everything below reads the CATALOG record, the fourth
812
+ # document of the malformed-document family and the only one decoded
813
+ # by `json.loads` + BARE SUBSCRIPT -- so its failure mode is
814
+ # `KeyError`, not the shape failure the three YAML documents produce.
815
+ # `record["supported"]["som_skus"]` (this line) and `record["example"]`
816
+ # (twice below) were bare double/single subscripts; the rest are
817
+ # guarded inside the helpers this function calls, each of which now
818
+ # takes the catalog path and this record's label so its curated
819
+ # message names THE FILE, THE FIELD and THE TYPE like the register at
820
+ # `tan/model/targets.py:312-323`. `find_template` has already required
821
+ # `record["id"] == template_id`, so labelling by id cannot itself lie.
822
+ rec_field = f"templates[{template_id!r}]"
823
+ supported = [
824
+ _require_field(entry, str, doc=catalog,
825
+ field=f"{rec_field}.supported.som_skus[{index}]")
826
+ for index, entry in enumerate(_require_key(
827
+ _require_key(record, "supported", dict,
828
+ doc=catalog, field=rec_field),
829
+ "som_skus", list, doc=catalog, field=f"{rec_field}.supported"))]
830
+ if sku not in supported:
831
+ raise SkuNotSupportedError(
832
+ f"{template_id}: sku {sku!r} is not supported "
833
+ f"(supported: {sorted(supported)})")
834
+
835
+ files = _ordered_files(record, doc=catalog, field=rec_field)
836
+ resolved = _resolve_params(record, params, doc=catalog, field=rec_field)
837
+ base = base_dir or REPO
838
+ metadata_root = metadata_root or METADATA_ROOT
839
+ preset = _default_preset_for_sku(sku, metadata_root)
840
+
841
+ example_rel = _require_key(record, "example", str,
842
+ doc=catalog, field=rec_field)
843
+ example_dir = _safe_join(base, example_rel, what="template example directory")
844
+ board_yaml_path = example_dir / "board.yaml"
845
+ # The other half of the ABSENT-document pair (tan-cli#1077 review) --
846
+ # see `load_catalog`'s docstring for the five-site measurement. The
847
+ # catalog's `example:` is drift-checked by alp-sdk's own
848
+ # `check_template_catalog.py`, but that gate runs on the SDK, not here,
849
+ # so a hand-edited catalog pointing at a directory with no `board.yaml`
850
+ # reached the user as a raw `FileNotFoundError`.
851
+ #
852
+ # tan-cli#1116 fixed a narrowed `except OSError` here (a non-UTF-8
853
+ # board.yaml escaped raw); tan-cli#1085 folded the fixed body into
854
+ # `DocumentGuards.require_readable_text`, the same definition
855
+ # `read_catalog_document` calls for the catalog's own read. Message
856
+ # text unchanged, one definition instead of two.
857
+ board_yaml_text = _require_readable_text(
858
+ board_yaml_path, what="template example board.yaml")
859
+ # tan-cli#1052: the fifth sibling of the same malformed-YAML family
860
+ # tan-cli#1025 -> #1034 -> #1037/#1048 swept through the SoM preset
861
+ # and the board metadata. THIS document is the catalog template's
862
+ # own `examples/<...>/board.yaml`, and every one of the four reads
863
+ # below was bare -- all four reachable from one command, `--emit
864
+ # scaffold --template peripheral --sku E1M-V2N101`. Measured on the
865
+ # pre-fix tree:
866
+ #
867
+ # <doc> = "- one\n- two\n" -> AttributeError: 'list' object has
868
+ # no attribute 'get' (:1542)
869
+ # cores: 3 -> AttributeError: 'int' object has
870
+ # no attribute 'keys' (:1542)
871
+ # som: 3 -> AttributeError: 'int' object has
872
+ # no attribute 'get' (:1543)
873
+ # pins: 3 -> TypeError: 'int' object is not
874
+ # iterable (:1551)
875
+ #
876
+ # Guarded through the same `_require_mapping_doc`/`_require_field`
877
+ # register the other two documents now use, so the next field added
878
+ # here inherits the rule instead of starting a sixth round. NOT
879
+ # stricter than `metadata/schemas/board.schema.json`: `pins:` is
880
+ # `type: array` with no `minItems`, so `pins: []` (and an absent or
881
+ # `null` `pins:`) still passes, as does `preset:` being absent -- it
882
+ # is optional, and only consulted when `pins:` is non-empty.
883
+ #
884
+ # Each read normalises `None` ONLY -- `[] if raw is None else raw`,
885
+ # never the `raw or []` the other two documents use. `or` collapses
886
+ # every falsy value, so a PRESENT but illegal scalar would be
887
+ # emptied instead of refused; measured on the first cut of this fix
888
+ # (tan-cli#1052 review), `pins: 0` / `pins: false` / `pins: ''` /
889
+ # `cores: 0` all rendered while `pins: 3` and `pins: true` raised.
890
+ # That degrades to empty rather than to garbage, but it is exactly
891
+ # the unpinned residual sibling this PR exists to stop leaving
892
+ # behind, so the falsy scalars are refused too.
893
+ #
894
+ # tan-cli#1133: the PARSE is guarded too. tan-cli#1116 fixed the READ
895
+ # here and left `yaml.safe_load` bare one line down, so a template
896
+ # example `board.yaml` that decoded fine but did not parse still raised
897
+ # a raw `yaml.parser.ParserError` through `emit_scaffold` -- measured on
898
+ # 3.12.3, 3.13.15 and 3.14.7 alike. Same `_parse_yaml_mapping` the two
899
+ # `metadata/**` documents above now use.
900
+ example_doc = _parse_yaml_mapping(
901
+ board_yaml_text, path=board_yaml_path,
902
+ what="template example board.yaml")
903
+ raw_cores = example_doc.get("cores")
904
+ original_core_ids = list(_require_field(
905
+ {} if raw_cores is None else raw_cores, dict,
906
+ doc=board_yaml_path, field="cores").keys())
907
+ raw_som = example_doc.get("som")
908
+ example_sku = _require_field(
909
+ {} if raw_som is None else raw_som, dict,
910
+ doc=board_yaml_path, field="som").get("sku", "")
911
+ core_renames = _derive_core_renames(original_core_ids, sku, metadata_root)
912
+ # `pins:` re-derivation (issue #876): each entry is either a bare
913
+ # pad string or a `{e1m, macro?, doc?}` mapping (same shape
914
+ # alp_orchestrate.loader's pins cross-check accepts); only the
915
+ # example's OWN preset (not an inline board def -- no catalog
916
+ # template ships one today) has a metadata/boards/<preset>.yaml to
917
+ # re-derive against.
918
+ raw_pins = example_doc.get("pins")
919
+ original_pins = list(_require_field(
920
+ [] if raw_pins is None else raw_pins, list,
921
+ doc=board_yaml_path, field="pins"))
922
+ source_preset = example_doc.get("preset")
923
+ if source_preset is not None:
924
+ # Same shape as `default_board:` one document over: a `preset:`
925
+ # that isn't a string reaches `metadata/boards/<preset>.yaml`
926
+ # as a path component and surfaces a curated-but-untrue message
927
+ # (measured pre-fix: `no metadata/boards/['a'].yaml for board
928
+ # ['a']`, which reads like a missing file rather than a
929
+ # malformed field). `None` is legal -- an inline board def, or
930
+ # a template with no `pins:` to re-derive.
931
+ _require_field(source_preset, str,
932
+ doc=board_yaml_path, field="preset")
933
+ pin_renames = (
934
+ _derive_pin_renames(original_pins, sku, source_preset, metadata_root)
935
+ if original_pins else {}
936
+ )
937
+ pin_macro_renames = (
938
+ _derive_pin_macro_renames(original_pins, sku, source_preset, metadata_root)
939
+ if original_pins else {}
940
+ )
941
+ pin_doc_renames = (
942
+ _derive_pin_doc_renames(original_pins, sku, source_preset, metadata_root)
943
+ if original_pins else {}
944
+ )
945
+ # README board-target rewrite (MAJOR C) still keys off a single
946
+ # "primary" app core -- the first m-prefixed core in
947
+ # original_core_ids, matching board.yaml's own declaration order
948
+ # (the same tie-break `_derive_core_renames`'s MAJOR D picks). One
949
+ # board id in the README prose is all MAJOR C ever rewrote, single-
950
+ # core template or not.
951
+ app_core_old = next((c for c in original_core_ids if c.startswith("m")), None)
952
+ app_core_sub = (
953
+ (app_core_old, core_renames[app_core_old])
954
+ if core_renames and app_core_old in core_renames else None
955
+ )
956
+ source_board = _core_board(example_sku, app_core_old, metadata_root)
957
+ target_board = _core_board(
958
+ sku, app_core_sub[1] if app_core_sub else app_core_old, metadata_root)
959
+ docs_ref = _docs_ref(base)
960
+ out: list[tuple[str, str]] = []
961
+ for rel, data in _rendered_bytes(
962
+ template_id, record, files, resolved, base,
963
+ doc=catalog, field=rec_field):
964
+ try:
965
+ text = data.decode("utf-8")
966
+ except UnicodeDecodeError as exc:
967
+ # render() copies any file as raw bytes; the JSON envelope
968
+ # cannot -- a future binary user_owned asset must fail
969
+ # cleanly here, not escape _run_scaffold_emit's `except
970
+ # TemplateError` as a raw traceback.
971
+ raise TemplateError(
972
+ f"{template_id}: {rel} is not valid UTF-8 text, cannot "
973
+ f"be JSON-encoded for --emit scaffold ({exc})") from exc
974
+ if rel == "board.yaml":
975
+ text = _substitute_board_yaml_sku(text, sku, preset)
976
+ for old, new in (core_renames or {}).items():
977
+ text = _substitute_board_yaml_core(text, old, new)
978
+ if pin_renames:
979
+ text = _substitute_board_yaml_pins(text, pin_renames, original_pins)
980
+ if pin_macro_renames:
981
+ text = _substitute_board_yaml_pin_macros(text, pin_macro_renames)
982
+ if pin_doc_renames:
983
+ text = _substitute_board_yaml_pin_docs(text, pin_doc_renames)
984
+ # A renamed pad can also survive as a stale mention in
985
+ # unrelated prose (issue #876 review MINOR 4) -- e.g.
986
+ # gpio-button-led's `preset:` header comment names the
987
+ # E1M-EVK pad in a continuation line the `preset:`
988
+ # substitution above never touches. Same treatment
989
+ # `_substitute_board_yaml_core` already gives a renamed
990
+ # core id (`_strip_stale_core_prose`).
991
+ for old in (pin_renames or {}):
992
+ text = _strip_stale_core_prose(text, old)
993
+ # alp-sdk#1855: board.yaml comments carry the same kind of
994
+ # bare alp-sdk-tree-only cross-reference README.md does
995
+ # (see `_BARE_REPO_PATH_RE`), but never went through any
996
+ # rewrite -- only README.md did.
997
+ text = _scaffold_bare_repo_paths(text, docs_ref)
998
+ elif rel.endswith("CMakeLists.txt"):
999
+ text = _scaffold_cmakelists(text)
1000
+ elif rel == "README.md":
1001
+ text = _scaffold_readme(
1002
+ text, example_rel, docs_ref,
1003
+ example_sku=example_sku, sku=sku,
1004
+ source_board=source_board, target_board=target_board,
1005
+ pin_renames=pin_renames)
1006
+ elif rel.endswith((".c", ".h")):
1007
+ # Same alp-sdk#1855 gap as board.yaml above -- a source
1008
+ # comment (e.g. cold-chain-monitor's src/main.c "(see
1009
+ # examples/ai/cold-chain-monitor/models/README.md)") is
1010
+ # never touched by any existing scaffold-adaptation pass.
1011
+ text = _scaffold_bare_repo_paths(text, docs_ref)
1012
+ out.append((rel, text))
1013
+ return out
1014
+
1015
+
1016
+ def emit_scaffold(
1017
+ template_id: str,
1018
+ sku: str,
1019
+ params: dict[str, Any] | None = None,
1020
+ *,
1021
+ catalog_path: Path | None = None,
1022
+ base_dir: Path | None = None,
1023
+ metadata_root: Path | None = None,
1024
+ ) -> str:
1025
+ """`--emit scaffold`'s STDOUT, byte for byte.
1026
+
1027
+ RELOCATED from `alp_project._run_scaffold_emit`'s two serialising lines. The
1028
+ envelope is `[{path, contents}, ...]` -- `render_to_envelope`'s ordered
1029
+ pairs, `json.dumps(..., indent=2)`, one trailing newline -- and alp-sdk's
1030
+ `scripts/check_emit_snapshots.py` diffs exactly these bytes against a
1031
+ committed golden, so the shape is the contract, not a presentation choice.
1032
+ """
1033
+ envelope = render_to_envelope(
1034
+ template_id, sku, params, catalog_path=catalog_path, base_dir=base_dir,
1035
+ metadata_root=metadata_root)
1036
+ return json.dumps(
1037
+ [{"path": p, "contents": c} for p, c in envelope], indent=2) + "\n"