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,1456 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan generate` -- run the board-derived emitters and report what landed.
3
+
4
+ **tan invents no output of its own**, but it no longer spawns alp-sdk to produce
5
+ it either. All TWELVE targets render in-process from `tan.planner`
6
+ (`tan.planner_emit.IN_PROCESS_MODES`) -- the five modes the planner relocation
7
+ brought over first, the six whose renderers moved with it (`dts-overlay`,
8
+ `native-sim-overlay`, `hw-info-h`, `west-libraries`, `carrier-netlist` and
9
+ `zephyr-board`), plus `composed-route-table` (the older debug view of
10
+ `carrier-netlist`'s own route rows, ported alongside it). No interpreter on
11
+ PATH, no PYTHONPATH, no process.
12
+
13
+ The spawned engine survives as an ESCAPE HATCH, not a code path anything reaches
14
+ by default:
15
+
16
+ <python> <sdk>/scripts/alp_project.py --input <board.yaml> \
17
+ --emit <mode> --output <path> [--core <id>]
18
+
19
+ It runs only when `TAN_GENERATE_EXECUTOR=subprocess` pins it, or when the
20
+ in-process path cannot serve the checkout at all. Keeping it is what lets the
21
+ parity suite render the same target both ways and diff the bytes.
22
+
23
+ `tan.planner_emit` documents how `metadata/emit-registry-v1.json` decides which
24
+ renderer owns which mode. Either way the emitted bytes, the schemas behind them,
25
+ and every hardware fact they encode stay in alp-sdk (ADR-0017; I-26). There is no
26
+ template here, no SKU list, no address, no pin name -- `som.sku` is read from the
27
+ customer's own `board.yaml` purely to spell one directory name, exactly as the
28
+ oracle does.
29
+
30
+ **A fall back to spawning is never silent.** When the in-process path cannot
31
+ serve a checkout (not a usable checkout, unreadable emit registry, or a `tan`
32
+ build without `PyYAML`/`jsonschema`), the run still succeeds via the subprocess
33
+ -- but it says so, with a `generate.in-process-unavailable` warning naming the
34
+ reason and a `data.engine` that reports `subprocess` for every target.
35
+ `TAN_GENERATE_EXECUTOR=subprocess` pins the old path deliberately;
36
+ `=in-process` refuses rather than falling back, which is what makes the parity
37
+ suite able to assert the engine it thinks it measured.
38
+
39
+ A resolvable SDK checkout is still REQUIRED on both engines -- `metadata/**`
40
+ never relocated -- so without one this command refuses
41
+ (`generate.sdk-root-unresolved`). I-31 pins `scripts/alp_project.py` as tan's
42
+ SDK-root marker; the invariant that forbids shelling the SDK (I-32, and
43
+ anti-pattern 22 beside it) is scoped to `tan init`/`tan scaffold`, which read a
44
+ VENDORED `--emit scaffold` tree so they work with no checkout at all.
45
+
46
+ **Every failure path is an envelope, never a traceback.** A missing or
47
+ unreadable `board.yaml`, an unresolved SDK, a bad `--target`/`--core` pairing, a
48
+ `native_sim` overlay that would be truncated, an interpreter too old to run the
49
+ SDK scripts, a spawn that dies, returns garbage, or exits 0 without producing
50
+ its artefact (tan-cli#397) -- each has a
51
+ `generate.<code>` issue and an exit code below, and the command carries a
52
+ catch-all backstop for anything unforeseen. The extension parses stdout whole:
53
+ a traceback there renders as nothing at all, with no error.
54
+
55
+ The in-process engine WIDENS that surface, which is why `_emit_one_in_process`
56
+ catches as broadly as it does: a planner blow-up that used to die inside a
57
+ subprocess and arrive as an exit code now propagates into this process, and
58
+ `tan.planner.loader` reports a missing dependency by calling `sys.exit` -- a
59
+ `SystemExit`, which no `except Exception` would stop.
60
+
61
+ Exit codes, verbatim from the oracle: missing board / unresolved SDK / bad
62
+ target / unresolvable SKU are ValidationFailure (2); a too-old interpreter is
63
+ RuntimeFailure (1); any target whose emit failed is WriteFailure (3), as is
64
+ the overlay guard for an EXPLICIT `--target native-sim-overlay` (tan-cli#457's
65
+ DELIBERATE divergence: the oracle's own is existence-only and refuses a re-run
66
+ forever; ours is content-aware) -- but SUCCESS (0), dropped with a warning
67
+ instead, when it only rides along in the bare/`--all` default set (tan-cli#501).
68
+
69
+ **`--output` exists because CMake, not tan, decides where a Zephyr build reads
70
+ from.** Today, 98 of alp-sdk's example `CMakeLists.txt` files (of 167 total)
71
+ shell `${ALP_SDK_ROOT}/scripts/alp_project.py --input <board.yaml> --emit
72
+ zephyr-conf` directly at configure time, and 86 of those 98 read the result
73
+ from `${CMAKE_BINARY_DIR}/generated/alp.conf` (the other 12 read it from a
74
+ `CMAKE_CURRENT_BINARY_DIR`-relative path instead); `CMAKE_BINARY_DIR` is NOT the
75
+ project directory (an in-tree `west build examples/<...>` puts it under the
76
+ repo root). Without `--output`, `tan` would emit into
77
+ `<project>/build/generated/` and that build would read nothing. PLANNED, not
78
+ yet merged into any released alp-sdk or its `dev`/`main` (tan-cli#825): a
79
+ shared `cmake/alp.cmake` helper that replaces the 98 copy-pasted calls and
80
+ PROBES `generate --help` for `--output`, falling back to shelling
81
+ `scripts/alp_project.py` when it is absent. So the flag is what will make
82
+ `tan` the emitter for every example once that helper ships, without changing
83
+ anything that already works -- and it is already useful today for pointing
84
+ `tan generate` at a build dir that is not `<project>/build/`.
85
+
86
+ `--output` writes a FILE. It never puts the file's contents on stdout: stdout
87
+ carries the envelope and nothing else, in both formats.
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import contextlib
93
+ import os
94
+ import subprocess
95
+ import sys
96
+ from dataclasses import dataclass
97
+ from pathlib import Path
98
+
99
+ import typer
100
+
101
+ # One home each for two decisions this command shares with the rest of the CLI:
102
+ # where an alp-sdk checkout is (I-31's marker, spelled once in `build_cmd`), and
103
+ # which interpreter name the SDK's own scripts run under -- a PATH name, never
104
+ # `sys.executable`, which is `tan` itself once PyInstaller has frozen it.
105
+ from tan import planner_emit
106
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
107
+ from tan.core.sdk_discovery import with_sdk_search
108
+ from tan.commands.doctor_cmd import probe, resolve_manifest_python_floor
109
+ from tan.core.fs_confine import PathEscapeError, resolve_confined
110
+ from tan.core.global_flags import accept_global_flags
111
+ from tan.core.link_refusal import refusal_code, split_coded_message
112
+ from tan.core.scaffold import top_level_key_name
113
+ from tan.core.sdk_discovery import (
114
+ _planner_python,
115
+ global_default_foreign_project_issue,
116
+ project_pin_issue,
117
+ resolve_sdk_root_wide,
118
+ sdk_ladder_divergence_issue,
119
+ )
120
+ from tan.core.shapes import rejected_sdk_root_message
121
+ from tan.core.subprocess_env import spawn_env
122
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
123
+ from tan.exit_codes import ExitCode
124
+ from tan.output_format import FORMAT_HELP, OutputFormat
125
+
126
+ #: `data.schemaVersion` for this command's payload.
127
+ DATA_SCHEMA_VERSION = "1"
128
+
129
+ #: Pin the engine instead of letting `auto` decide. An env var, not a flag:
130
+ #: the PLANNED `cmake/alp.cmake` (unmerged, tan-cli#825) will build one argv
131
+ #: that must stay accepted verbatim, and this is an escape hatch for a host
132
+ #: where the in-process path misbehaves plus the seam the parity suite uses
133
+ #: to compare both engines against the same oracle.
134
+ #:
135
+ #: `subprocess` spawns alp-sdk for every target (the pre-relocation behaviour).
136
+ #: `in-process` refuses instead of falling back, so a run cannot quietly measure
137
+ #: the engine it was not testing.
138
+ EXECUTOR_ENV = "TAN_GENERATE_EXECUTOR"
139
+ EXECUTORS = ("auto", "in-process", "subprocess")
140
+
141
+ #: Every emit mode a bare `tan generate` / `--all` runs, in the order
142
+ #: `data.targets` reports them, mapped to its output path relative to the
143
+ #: workspace root. ONE structure, deliberately: the Rust carries the mode list
144
+ #: and the path table separately (`tan_core::ALL_EMIT_MODES` plus
145
+ #: `GENERATION_TARGET_CATALOG`) and tan-cli#165 exists because those two drifted
146
+ #: apart silently, the catalog having grown only four of the nine.
147
+ #:
148
+ #: The literals are `/`-separated because they are SDK conventions, not host
149
+ #: paths -- `_output_path` folds them component-wise so a Windows run cannot
150
+ #: emit `build/generated/alp.conf` beside `build\boards\<dir>` in one envelope
151
+ #: (tan-cli#165 review finding 4).
152
+ #:
153
+ #: `native-sim-overlay` is the one entry outside `build/generated/`: Zephyr
154
+ #: auto-discovers `boards/<board>.overlay` in the app's own SOURCE tree, so
155
+ #: `west build -b native_sim/native/64` only finds it there. That it writes into
156
+ #: a hand-editable tree is why `_overlay_would_overwrite` exists.
157
+ _OUTPUT_RELATIVE_PATH = {
158
+ "zephyr-conf": "build/generated/alp.conf",
159
+ "dts-overlay": "build/generated/alp.overlay",
160
+ "native-sim-overlay": "boards/native_sim_native_64.overlay",
161
+ "cmake-args": "build/generated/alp-cmake-args.txt",
162
+ "yocto-conf": "build/generated/alp-yocto.conf",
163
+ "carrier-netlist": "build/generated/carrier-netlist.json",
164
+ "west-libraries": "build/generated/alp-west-libs.yml",
165
+ "hw-info-h": "build/generated/alp_hw_info_build.h",
166
+ "os-topology": "build/generated/os-topology.json",
167
+ # Explicit-only (see EXPLICIT_ONLY_TARGETS) -- still in this table because
168
+ # each has one canonical path.
169
+ # `composed-route-table` is the older debug/demonstrator view of
170
+ # `carrier-netlist`'s own route rows -- alp-sdk's docs/cli.md lists it as
171
+ # "JSON route-table dump (demonstrator)", a maintainer-only tool with no
172
+ # product consumer. The Rust oracle's `tan_core::ALL_EMIT_MODES`
173
+ # (crates/tan-core/src/loader.rs) excludes it from the default/`--all`
174
+ # set for the same reason, so a bare `tan generate` never runs a full
175
+ # pad-route composition.
176
+ "composed-route-table": "build/generated/composed-route-table.json",
177
+ # `alp_sdk_ipc_contract_header()`, a helper PLANNED for alp-sdk's
178
+ # `cmake/alp.cmake` (unmerged, tan-cli#825), names exactly this one:
179
+ # `${CMAKE_BINARY_DIR}/generated/alp/system_ipc.h`.
180
+ "ipc-contract-h": "build/generated/alp/system_ipc.h",
181
+ }
182
+
183
+ #: The one target that is NOT defaultable: it hard-requires `--core` and writes
184
+ #: a DIRECTORY of files named per SKU+core, so a bare `tan generate` has no path
185
+ #: to give it. Reachable only via an explicit `--target zephyr-board --core <id>`.
186
+ ZEPHYR_BOARD = "zephyr-board"
187
+
188
+ #: The cross-core IPC contract header (`<alp/system_ipc.h>`), the second target
189
+ #: reachable only by an explicit `--target`. Not core-scoped -- the contract IS
190
+ #: the agreement between cores -- and not defaultable either: it renders a
191
+ #: board.yaml `ipc:` block, which four multicore examples have and the other 95
192
+ #: do not, so folding it into the default set would make a bare `tan generate`
193
+ #: report a failed target on almost every project.
194
+ IPC_CONTRACT_H = "ipc-contract-h"
195
+
196
+ #: The pad-route debug/demonstrator view (see `_OUTPUT_RELATIVE_PATH`'s
197
+ #: comment). Not defaultable: it is a maintainer-only tool with no product
198
+ #: consumer, so a bare `tan generate` must not pay for a full pad-route
199
+ #: composition on every project -- a prior revision listed this in
200
+ #: `_OUTPUT_RELATIVE_PATH` without also listing it here, which silently grew
201
+ #: the default set from 9 targets to 10.
202
+ COMPOSED_ROUTE_TABLE = "composed-route-table"
203
+
204
+ #: Targets an explicit `--target` can reach but the default/`--all` set never
205
+ #: does. `zephyr-board` is absent from `_OUTPUT_RELATIVE_PATH` (it writes a
206
+ #: directory, named per SKU+core); `composed-route-table` and
207
+ #: `ipc-contract-h` are both in it.
208
+ EXPLICIT_ONLY_TARGETS = frozenset(
209
+ {ZEPHYR_BOARD, COMPOSED_ROUTE_TABLE, IPC_CONTRACT_H}
210
+ )
211
+
212
+ #: The default/`--all` target set. Derived, so it cannot disagree with the path
213
+ #: table above.
214
+ ALL_EMIT_MODES = tuple(
215
+ mode for mode in _OUTPUT_RELATIVE_PATH if mode not in EXPLICIT_ONLY_TARGETS
216
+ )
217
+
218
+ #: Targets `--core` optionally SCOPES, beyond `zephyr-board` which requires it.
219
+ #: Verbatim from `alp_project.py`'s own `--core` help. `carrier-netlist`,
220
+ #: `composed-route-table` and `native-sim-overlay` are excluded because the SDK
221
+ #: never reads `--core` for them at all, and `os-topology` because it accepts
222
+ #: the flag and then prints `--core is ignored for --emit os-topology
223
+ #: (project-level emit)` and ignores it -- the same "does nothing" shape, so all
224
+ #: four are refused rather than silently accepted.
225
+ CORE_SCOPABLE_TARGETS = (
226
+ "zephyr-conf",
227
+ "yocto-conf",
228
+ "cmake-args",
229
+ "dts-overlay",
230
+ "west-libraries",
231
+ "hw-info-h",
232
+ )
233
+
234
+ #: Seconds a single `alp_project.py` emit may take. Generous: a cold emit
235
+ #: imports the whole orchestrator package and reads every metadata file for the
236
+ #: SKU. Bounded regardless, so a planner that wedges cannot hang a `--format
237
+ #: json` consumer with no envelope and no error -- the failure mode a missing
238
+ #: timeout produces is indistinguishable from a slow build.
239
+ EMIT_TIMEOUT_S = 300
240
+
241
+
242
+ class GenerateError(Exception):
243
+ """A refusal whose issue code and exit code are already decided.
244
+
245
+ `extra_issues` (tan-cli#900) are prepended ahead of the refusal's own
246
+ `Issue` by `refuse()` -- the `[*resolution_issues, Issue(...)]` shape
247
+ `clean_cmd._run` already uses. `None` by default: only the SDK-root-
248
+ unresolved raise below needs it (a broken pin surviving a refusal that
249
+ precedes resolution); every other raise here fires after `sdk_broken_pin`
250
+ is already known and reported through the success-path `issues` instead.
251
+ """
252
+
253
+ def __init__(
254
+ self,
255
+ code: str,
256
+ message: str,
257
+ exit_code: ExitCode,
258
+ extra_issues: list[Issue] | None = None,
259
+ ) -> None:
260
+ super().__init__(message)
261
+ self.code = code
262
+ self.message = message
263
+ self.exit_code = exit_code
264
+ self.extra_issues = extra_issues
265
+
266
+
267
+ def zephyr_board_dir_name(sku: str, core_id: str) -> str | None:
268
+ """`som.sku` + core id -> the SDK's `--emit zephyr-board` directory name:
269
+ `E1M-AEN801` + `m55_hp` -> `alp_e1m_aen801_m55_hp`.
270
+
271
+ `alp_project.py` keys every generated file under exactly this name and
272
+ strips it before joining onto `--output`, so `--output` must BE this
273
+ directory. A bare core id would collide across two SoMs that share one
274
+ (tan-cli#116 review finding 1) -- a project retargeted from E1M-AEN901 to
275
+ E1M-AEN801 would land each run's files beside the other SoM's stale ones.
276
+
277
+ `None` on any other prefix, mirroring the SDK's own `unrecognised SKU
278
+ prefix` guard. This is a naming convention, not a SKU list: no SKU is
279
+ enumerated anywhere in tan.
280
+ """
281
+ if not sku.startswith("E1M-"):
282
+ return None
283
+ return f"alp_e1m_{sku[len('E1M-'):].lower()}_{core_id}"
284
+
285
+
286
+ def _board_sku(path: Path) -> str | None:
287
+ """`som.sku` out of `board.yaml`, or `None` for every way that can fail.
288
+
289
+ `None` is not an error here -- the caller turns it into
290
+ `generate.board-sku-unresolved`, which is exactly what the oracle's
291
+ `read_to_string(..).ok().and_then(..)` chain produces for an absent,
292
+ unreadable, non-UTF-8 or wrong-shaped file. So a bad board.yaml is a
293
+ refusal with a code, never a traceback.
294
+
295
+ PyYAML when it is importable (a Zephyr workspace needs it anyway), else a
296
+ two-line scan for the one nested key this needs. tan ships no YAML
297
+ dependency, and the same reasoning as `validate_cmd._load_yaml` applies:
298
+ answer only the question actually asked.
299
+ """
300
+ try:
301
+ text = path.read_text(encoding="utf-8")
302
+ except (OSError, UnicodeDecodeError):
303
+ return None
304
+
305
+ try:
306
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
307
+ except ImportError:
308
+ return _scan_som_sku(text)
309
+ try:
310
+ doc = yaml.safe_load(text)
311
+ except Exception: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises
312
+ return None
313
+ if not isinstance(doc, dict):
314
+ return None
315
+ som = doc.get("som")
316
+ if not isinstance(som, dict):
317
+ return None
318
+ sku = som.get("sku")
319
+ return sku if isinstance(sku, str) and sku else None
320
+
321
+
322
+ def _scan_som_sku(text: str) -> str | None:
323
+ """The no-PyYAML fallback: the `sku:` scalar inside the top-level `som:`
324
+ block. Deliberately not a YAML parser -- it answers one question.
325
+
326
+ tan-cli#1008 review round 5: the top-level-key check is
327
+ `tan.core.scaffold.top_level_key_name`, shared with
328
+ `bootstrap_cmd._scan_board_slice` and `scaffold._is_som_key_line`
329
+ rather than a fourth local copy of the same rule."""
330
+ inside = False
331
+ for raw in text.splitlines():
332
+ stripped = raw.strip()
333
+ if not stripped or stripped.startswith("#"):
334
+ continue
335
+ if not raw[:1].isspace():
336
+ inside = top_level_key_name(stripped) == "som"
337
+ continue
338
+ if inside:
339
+ key, sep, value = stripped.partition(":")
340
+ if sep and key.strip() == "sku":
341
+ return value.strip().strip("'\"") or None
342
+ return None
343
+
344
+
345
+ def resolve_targets(
346
+ target: str | None, all_targets: bool, core: str | None
347
+ ) -> tuple[str, ...]:
348
+ """Which emit modes to run. Raises `GenerateError` for every user-supplied
349
+ argument-shape mistake.
350
+
351
+ ValidationFailure, never InternalFailure: an unknown `--target` or a
352
+ `--core` paired with a target that does not consume it is an ordinary usage
353
+ mistake, and it used to surface identically to a genuine tan bug
354
+ (tan-cli#117 review finding 3). `--core` is refused rather than silently
355
+ ignored -- silently ignoring it lets a user believe it scoped something.
356
+ """
357
+
358
+ def invalid(message: str) -> GenerateError:
359
+ return GenerateError(
360
+ "generate.invalid-target", message, ExitCode.VALIDATION_FAILURE
361
+ )
362
+
363
+ if all_targets and target is not None:
364
+ # tan-cli#498 defect 5. `--all` and `--target` name two different
365
+ # target sets, and `--all` used to win silently at the `return
366
+ # ALL_EMIT_MODES` below -- so `--all --target zephyr-board` DISCARDED
367
+ # the target the user named (`zephyr-board` is not in ALL_EMIT_MODES,
368
+ # so `build/boards/` was never created) with no field in the envelope
369
+ # recording the discard. It also opened the hole the `--core` guard
370
+ # below was written to close: with `target == ZEPHYR_BOARD` the guard
371
+ # accepted a `--core` that then narrowed six of the nine default emits
372
+ # (measured on `examples/bringup/board-selftest`:
373
+ # `build/generated/alp.conf` 3799 -> 1867 bytes, the a32_cluster slice
374
+ # and the `# --- core: ... ---` markers gone), while the run ended exit
375
+ # 3 blaming `yocto-conf` for an error the flag COMBINATION caused.
376
+ #
377
+ # The frozen oracle drops the target just as silently (measured: `--all
378
+ # --target zephyr-conf` returns the full default set at exit 0), and no
379
+ # golden pins the combination -- so this refusal is a deliberate,
380
+ # unpinned divergence. Refusing beats picking a winner: either choice
381
+ # ignores half of what was typed.
382
+ raise invalid(
383
+ f"`--all` and `--target {target}` name different target sets; pass one "
384
+ "or the other. `--all` (the default set) runs "
385
+ f"{', '.join(ALL_EMIT_MODES)}."
386
+ )
387
+
388
+ if core is not None:
389
+ # `not all_targets` on BOTH arms since #498: with the guard above in
390
+ # front, `all_targets` implies `target is None`, so the qualifier is
391
+ # belt-and-braces -- but its absence on the `ZEPHYR_BOARD` arm is
392
+ # precisely what let `--all --target zephyr-board --core <id>` past a
393
+ # message whose own text says `--core` "does nothing for the
394
+ # default/--all target set".
395
+ core_is_valid_here = not all_targets and (
396
+ target == ZEPHYR_BOARD or target in CORE_SCOPABLE_TARGETS
397
+ )
398
+ if not core_is_valid_here:
399
+ raise invalid(
400
+ "`--core` is only valid with `--target zephyr-board` (required), or "
401
+ f"one of {', '.join(CORE_SCOPABLE_TARGETS)} (optional scoping); it "
402
+ "does nothing for the default/--all target set."
403
+ )
404
+
405
+ if all_targets or target is None:
406
+ return ALL_EMIT_MODES
407
+
408
+ if target == ZEPHYR_BOARD:
409
+ if core is None:
410
+ raise invalid(
411
+ "`--target zephyr-board` requires `--core <id>` (it generates one "
412
+ "core's Zephyr board tree)."
413
+ )
414
+ return (ZEPHYR_BOARD,)
415
+
416
+ if target in _OUTPUT_RELATIVE_PATH:
417
+ return (target,)
418
+
419
+ raise invalid(f"Unsupported generate target '{target}'.")
420
+
421
+
422
+ def _output_path(
423
+ workspace_root: Path, emit: str, board_dir_name: str | None
424
+ ) -> Path:
425
+ """Where `emit`'s output goes, with native separators throughout.
426
+
427
+ `zephyr-board` is the one target with no literal path: it writes a
428
+ directory per SKU+core under `build/boards/`, so
429
+ `west build --board-root build/boards` finds every generated board tree
430
+ without them colliding.
431
+ """
432
+ if emit == ZEPHYR_BOARD:
433
+ # `run` resolves the name before reaching this target (refusing
434
+ # otherwise), so the fallback is only for a direct call.
435
+ return workspace_root / "build" / "boards" / (board_dir_name or "board")
436
+ path = workspace_root
437
+ for part in _OUTPUT_RELATIVE_PATH[emit].split("/"):
438
+ path = path / part
439
+ return path
440
+
441
+
442
+ def _resolve_output_override(
443
+ output: str, targets: tuple[str, ...], workspace_root: Path
444
+ ) -> Path:
445
+ """`--output` as an absolute path. Touches nothing on disk.
446
+
447
+ `--output` names ONE destination, so it needs exactly one target: paired
448
+ with the default set (or `--all`) it would silently mean "overwrite this
449
+ file eight times", the last emit winning. That is a usage mistake, so it is
450
+ ValidationFailure with the same `generate.invalid-target` code the other
451
+ argument-shape refusals carry.
452
+ """
453
+ if len(targets) != 1:
454
+ raise GenerateError(
455
+ "generate.invalid-target",
456
+ "`--output` names a single destination, so it requires exactly one "
457
+ "`--target <mode>`; it cannot serve the default/--all target set.",
458
+ ExitCode.VALIDATION_FAILURE,
459
+ )
460
+ candidate = Path(output)
461
+ return candidate if candidate.is_absolute() else workspace_root / candidate
462
+
463
+
464
+ def _ensure_writable(output: Path, target: str) -> bool:
465
+ """Create `output`'s parent and prove the destination is writable.
466
+
467
+ Returns True when the probe ITSELF created the destination file -- which it
468
+ then REMOVES again before returning, so nothing tan invented is on disk
469
+ when the emitter runs.
470
+
471
+ Done HERE rather than discovered when the spawned emitter trips over it, for
472
+ two reasons: a `PermissionError` traceback on the SDK's stderr is a terrible
473
+ issue message, and the parent directory has to be created either way --
474
+ real example `CMakeLists.txt` today ask for
475
+ `${CMAKE_BINARY_DIR}/generated/alp.conf` (and the PLANNED
476
+ `alp_sdk_zephyr_conf()`, unmerged, tan-cli#825, would do the same) in
477
+ a build tree where `generated/` does not exist yet.
478
+
479
+ **The unlink is tan-cli#498 defect 4's fix, and it is structural.** The
480
+ probe used to LEAVE its zero-byte file behind, which made "the file exists"
481
+ useless as proof that the emitter wrote anything -- so
482
+ `_missing_emit_output` demanded a NON-EMPTY file instead, and then failed
483
+ every emit that legitimately produces zero bytes. Removing the probe's own
484
+ artefact restores existence as honest evidence. It also retires the window
485
+ tan-cli#420 patched from the other end: no stray zero-byte
486
+ `build/generated/alp.conf` survives for a later standalone `west build` to
487
+ pick up as a valid (and silently empty) `EXTRA_CONF_FILE`.
488
+
489
+ It cannot cost a pre-existing file: `existed` is read BEFORE the `open`,
490
+ and a file that was already there is never touched (`"ab"` does not
491
+ truncate).
492
+
493
+ `ValueError` is caught beside `OSError` deliberately: a path carrying a
494
+ surrogate or an embedded NUL raises `UnicodeEncodeError`/`ValueError`, not
495
+ `OSError`, and letting one reach the command's catch-all backstop would
496
+ report an unwritable path as InternalFailure (5) instead of WriteFailure (3).
497
+ """
498
+ try:
499
+ if target == ZEPHYR_BOARD:
500
+ # `--emit zephyr-board`'s `--output` IS the board directory.
501
+ output.mkdir(parents=True, exist_ok=True)
502
+ return False
503
+ output.parent.mkdir(parents=True, exist_ok=True)
504
+ # Checked BEFORE the open, which is what creates it. Afterwards there
505
+ # is no way left to tell tan's probe from a file the user already had.
506
+ existed = output.exists()
507
+ # Append mode, not write: proving writability must not truncate a
508
+ # file whose emit could still be refused further down.
509
+ with output.open("ab"):
510
+ pass
511
+ if not existed:
512
+ # Best-effort: on the platform where this could fail (another
513
+ # process grabbed the name between the open and here) the emit is
514
+ # about to overwrite it anyway, and refusing a run over a
515
+ # successful writability proof would be the worse answer.
516
+ with contextlib.suppress(OSError):
517
+ output.unlink()
518
+ return True
519
+ return False
520
+ except (OSError, ValueError) as err:
521
+ raise GenerateError(
522
+ "generate.output-unwritable",
523
+ f"--output path `{output}` cannot be written: {err}",
524
+ ExitCode.WRITE_FAILURE,
525
+ ) from err
526
+
527
+
528
+ def _discard_probe_file(output: Path) -> None:
529
+ """Remove a zero-byte destination file left behind by a REFUSED emit at a
530
+ path this run created (tan-cli#420).
531
+
532
+ Narrower since tan-cli#498 defect 4 moved the probe's own cleanup into
533
+ `_ensure_writable` (which now unlinks what it created immediately, rather
534
+ than leaving it for this): what is left for this function is the emitter
535
+ that opened the destination, wrote nothing, and then failed. Kept for
536
+ exactly that, and still correct for it.
537
+
538
+ The envelope was always honest about this -- exit 3, `data.written == []`,
539
+ `generate.emit-failed` -- so what is fixed here is the DISK, not the
540
+ report. The file that survives a refused run is a zero-byte
541
+ `build/generated/alp.conf`, and the real example `CMakeLists.txt` hands
542
+ exactly that path to Zephyr as `EXTRA_CONF_FILE` (the PLANNED
543
+ `cmake/alp.cmake`, unmerged, tan-cli#825, would do the same once it
544
+ ships). Zephyr does not treat an empty conf file
545
+ as an error: it applies no configuration and says nothing. So the stray
546
+ turns a loud failure into a later build that silently carries none of the
547
+ project's alp config.
548
+
549
+ Within tan's own flow the refusal stops the build, which is why leaving it
550
+ was defensible. It is not the only flow: standalone `west build` against a
551
+ tree where a `tan generate` failed earlier reaches that configure with
552
+ nobody re-running generate in between, and hand-written firmware bypassing
553
+ the tan-driven path is first-class in this SDK.
554
+
555
+ Zero-byte is re-checked at unlink time rather than trusted from the probe:
556
+ between the probe and the refusal an emitter may have written a partial
557
+ file, and a partial artefact is evidence for the user to look at, not
558
+ litter to remove. Cleanup failure is swallowed -- the run has already
559
+ failed for a reason worth reporting, and "could not tidy up" must not
560
+ replace it.
561
+ """
562
+ with contextlib.suppress(OSError):
563
+ if output.is_file() and output.stat().st_size == 0:
564
+ output.unlink()
565
+
566
+
567
+ def _confine_default_outputs(
568
+ workspace_root: Path, targets: tuple[str, ...], board_dir_name: str | None
569
+ ) -> None:
570
+ """Refuse the WHOLE run, before any target writes, if any DEFAULT output
571
+ path -- i.e. `--output` was not given -- would resolve outside the
572
+ project after symlink resolution (tan-cli#325): a pre-existing directory
573
+ symlink at, say, `<project>/build` used to carry `_output_path`'s literal
574
+ join to wherever that symlink pointed, while the envelope still reported
575
+ the logical in-project path (`build/generated/alp.conf`) as written.
576
+
577
+ `--output` is exempt on purpose -- it names the caller's OWN chosen
578
+ destination (see the module docstring's `--output` section: CMake, not
579
+ tan, decides where a Zephyr build reads from), so an explicit path there
580
+ is honoured as typed, not treated as an accidental escape.
581
+ """
582
+ for mode in targets:
583
+ candidate = _output_path(workspace_root, mode, board_dir_name)
584
+ try:
585
+ resolve_confined(workspace_root, candidate)
586
+ except (PathEscapeError, OSError, ValueError) as err:
587
+ raise GenerateError(
588
+ "generate.write-escapes-project",
589
+ f"target '{mode}' would write to '{candidate}', which resolves "
590
+ f"outside the project root '{workspace_root}' ({err}); refusing "
591
+ "to generate any target for this run.",
592
+ ExitCode.WRITE_FAILURE,
593
+ ) from err
594
+
595
+
596
+ _OVERLAY_BANNER = "--emit native-sim-overlay -- do not edit by hand."
597
+
598
+
599
+ def _overlay_would_overwrite(
600
+ workspace_root: Path,
601
+ targets: tuple[str, ...],
602
+ force: bool,
603
+ output_override: Path | None = None,
604
+ ) -> bool:
605
+ """True when this run would truncate a HAND-EDITED (or otherwise foreign)
606
+ `boards/native_sim_native_64.overlay` -- content-aware (like `init`/`scaffold`'s
607
+ `collect_file_changes`): a file lacking `_OVERLAY_BANNER` (`native_sim.py:87-88`'s
608
+ own) trips it. `output_override` replaces the default path when `--output`
609
+ redirected the emit.
610
+
611
+ Whether that turns into a whole-run refusal or a per-run target drop is the
612
+ CALLER's decision (tan-cli#501): an explicit `--target native-sim-overlay`
613
+ still refuses without `--force` (tan-cli#457); `native-sim-overlay` riding
614
+ along in the bare/`--all` default set instead drops the target and reports
615
+ an issue, so the caller always passes `force=False` here first to ask "is
616
+ this foreign at all" before deciding what to do about it.
617
+ """
618
+ if force or "native-sim-overlay" not in targets:
619
+ return False
620
+ destination = output_override or _output_path(
621
+ workspace_root, "native-sim-overlay", None
622
+ )
623
+ try:
624
+ return destination.exists() and _OVERLAY_BANNER not in destination.read_text(
625
+ encoding="utf-8"
626
+ )
627
+ except (OSError, UnicodeDecodeError):
628
+ return True
629
+
630
+
631
+ def _python_too_old(python: str, floor: tuple[int, int]) -> str | None:
632
+ """A message when `python` is below the SDK's floor, else `None`.
633
+
634
+ `floor` is the resolved SDK's OWN declared floor -- they use
635
+ `@dataclass(slots=True)`, so `resolve_manifest_python_floor` (I-24) reads
636
+ `<sdk>/metadata/bootstrap.json`'s `pythonMinVersion` live rather than a
637
+ second hardcoded 3.10 that could drift from the manifest's, or from
638
+ `model_cmd`'s own copy. `None` also covers "could not tell" -- a missing or
639
+ broken interpreter is a different failure the real spawn surfaces on its
640
+ own, and blocking on an unknown would refuse a perfectly good host. Bounded
641
+ by `probe`'s timeout, which is why this reuses it rather than spawning by
642
+ hand.
643
+ """
644
+ out = probe([python, "-c", "import sys;print('%d.%d' % sys.version_info[:2])"])
645
+ if out is None:
646
+ return None
647
+ try:
648
+ major, minor = (int(part) for part in out.strip().splitlines()[-1].split(".")[:2])
649
+ except (IndexError, ValueError):
650
+ return None
651
+ if (major, minor) >= floor:
652
+ return None
653
+ return (
654
+ f"Python {major}.{minor} found at `{python}`, but alp-sdk requires Python "
655
+ f"{floor[0]}.{floor[1]}+. Put a newer `python` first on PATH "
656
+ "(VS Code users can instead set alpSdk.pythonPath)."
657
+ )
658
+
659
+
660
+ def _relative_or_full(workspace_root: Path, output_path: Path) -> str:
661
+ try:
662
+ return str(output_path.relative_to(workspace_root))
663
+ except ValueError:
664
+ return str(output_path)
665
+
666
+
667
+ def _output_stamp(output: Path) -> tuple[int, int, int] | None:
668
+ """`(st_ino, st_size, st_mtime_ns)` for `output`, or `None` when it does
669
+ not exist (or cannot be stat'ed).
670
+
671
+ Taken immediately before a spawned emit and compared immediately after, so
672
+ `_missing_emit_output` can tell "the emitter wrote a legitimately empty
673
+ file" from "the emitter never touched a file that was already there" --
674
+ the one case where mere existence is no evidence at all. Three fields
675
+ rather than one: a rewrite that lands on the same size and the same
676
+ coarse timestamp still moves the inode on any emitter that writes through
677
+ a temp file + rename, and a same-inode rewrite moves `st_mtime_ns`.
678
+
679
+ Known limit, and the direction it errs in ON PURPOSE: on a filesystem with
680
+ coarse timestamps (FAT's 2s, HFS+'s 1s -- not ext4/APFS/NTFS, where this
681
+ runs), an in-place rewrite of byte-identical content inside one tick is
682
+ indistinguishable from no write at all, and would be reported as a failed
683
+ emit. That is the safe side of the trade: a false FAILURE is loud, costs a
684
+ re-run and destroys nothing, while the false SUCCESS it replaces put a
685
+ previous board's `alp.conf` into the next `tan build` silently.
686
+ """
687
+ try:
688
+ stat = output.stat()
689
+ except (OSError, ValueError):
690
+ return None
691
+ return (stat.st_ino, stat.st_size, stat.st_mtime_ns)
692
+
693
+
694
+ def _missing_emit_output(
695
+ emit: str, output: Path, before: tuple[int, int, int] | None = None
696
+ ) -> str | None:
697
+ """Why a ZERO exit did not actually produce `output`, or `None` when it did.
698
+
699
+ tan-cli#397: `_emit_one` used to read `returncode == 0` as proof that a file
700
+ landed, and `written[]` was built from nothing else. A checkout whose
701
+ `scripts/alp_project.py` exits 0 down a path tan did not expect -- a
702
+ partially installed or partially cloned alp-sdk, a differently configured
703
+ emitter, a future SDK version -- then produced `ok: true`, exit 0,
704
+ `failed: []` and a `written[]` naming a file that is not on disk. The next
705
+ `tan build` configures Zephyr against a stale `alp.conf` or none at all, and
706
+ the symptom surfaces much later as wrong Kconfig in a built image with
707
+ nothing in the generate envelope pointing back at the missing artefact.
708
+ alp-sdk-vscode believes `written[]` outright -- it is the only signal the
709
+ extension has for what a generate produced, so it performs no existence
710
+ check of its own.
711
+
712
+ PRESENT, not non-empty -- **tan-cli#498 defect 4 corrected this.** The check
713
+ used to demand a non-zero size, for a reason true at the time (with
714
+ `--output`, `_ensure_writable`'s `open("ab")` had already created a
715
+ zero-byte file, so existence alone would have accepted tan's own probe).
716
+ The premise it rested on -- "No real emit is empty" -- is FALSE.
717
+ `alp_project.py`'s unscoped per-core emits write nothing and exit 0 when no
718
+ core matches the mode's OS class, which is the CORRECT answer: on the
719
+ shipped `examples/display/lvgl-dashboard-x-evk` (a Linux-only V2N board),
720
+ `--emit zephyr-conf` prints `alp_project: wrote ref/alp.conf (0 bytes)` and
721
+ exits 0, as does `--emit cmake-args`. Measured, both engines, same tree:
722
+ in-process reported exit 0 with those files in `written[]`; spawned
723
+ reported exit 3, `failed: ["zephyr-conf","cmake-args"]`, "exited 0 but did
724
+ not write ..." -- about files that were on disk. Worse with `--output`:
725
+ `probe_created_output` was True, so `_discard_probe_file` then UNLINKED the
726
+ artefact the SDK really produced. It also broke the parity contract this
727
+ module's docstring rests on -- the two engines must render a target the
728
+ same way for the parity suite to diff their bytes.
729
+
730
+ The probe ambiguity that motivated the size demand is closed at its source
731
+ instead: `_ensure_writable` now REMOVES the file it created before the
732
+ emitter runs, so a file present afterwards is the emitter's, whatever its
733
+ size. tan-cli#397's guarantee is intact in BOTH of its cases:
734
+
735
+ * nothing at the destination -> the file is still absent afterwards, and
736
+ still refused;
737
+ * something already at the destination (a stale artefact, or a file the
738
+ caller's `--output` pointed at) -> `before` carries its
739
+ [`_output_stamp`], and a destination that comes out of the emit exactly
740
+ as it went in is refused too. Without that arm the widened check would
741
+ have called an untouched stale `alp.conf` "written" and handed the next
742
+ `tan build` a file from a previous board -- exactly #397's failure, one
743
+ level up.
744
+
745
+ Only the SPAWNED engine needs this. `_emit_one_in_process` performs the
746
+ write itself, so its `None` is already earned.
747
+ """
748
+ prefix = f"Generation failed for target '{emit}': the SDK emitter exited 0 but "
749
+ try:
750
+ if emit == ZEPHYR_BOARD:
751
+ if not output.is_dir():
752
+ return prefix + f"did not create the board directory `{output}`."
753
+ if not any(output.iterdir()):
754
+ return prefix + f"wrote no files into `{output}`."
755
+ return None
756
+ if not output.is_file():
757
+ return prefix + f"did not write `{output}`."
758
+ if before is not None and _output_stamp(output) == before:
759
+ return prefix + f"left `{output}` exactly as it found it."
760
+ except (OSError, ValueError) as err:
761
+ # Never swallowed into a success: a destination that cannot even be
762
+ # stat'ed is exactly the state this guard exists to refuse to call
763
+ # "written". `ValueError` sits beside `OSError` for the same reason
764
+ # `_ensure_writable` catches both -- a path carrying a surrogate or an
765
+ # embedded NUL raises that, and letting one reach the command's
766
+ # catch-all backstop would report it as InternalFailure (5).
767
+ return prefix + f"`{output}` could not be checked: {err}"
768
+ return None
769
+
770
+
771
+ def _emit_one(
772
+ python: str,
773
+ script: Path,
774
+ board_path: Path,
775
+ emit: str,
776
+ output: Path,
777
+ core: str | None,
778
+ ) -> str | None:
779
+ """Run one emit. `None` on success, else the message for its
780
+ `generate.emit-failed` issue.
781
+
782
+ Every way a subprocess can fail is a message, not an exception: the binary
783
+ is absent or not executable (`OSError`), it wedges (`TimeoutExpired`), or it
784
+ exits non-zero (the SDK's own stderr is the most useful message there, so it
785
+ is forwarded verbatim when present).
786
+
787
+ A zero exit is necessary but NOT sufficient -- `_missing_emit_output` has
788
+ the whole of why (tan-cli#397).
789
+ """
790
+ argv = [
791
+ python,
792
+ str(script),
793
+ "--input",
794
+ str(board_path),
795
+ "--emit",
796
+ emit,
797
+ "--output",
798
+ str(output),
799
+ ]
800
+ # zephyr-board requires --core (already validated); the scopable targets
801
+ # accept it optionally, and forwarding it is what makes `--target
802
+ # zephyr-conf --core m55_hp` byte-identical to what a build materialises for
803
+ # that core (tan-cli#117 review finding 2).
804
+ if core is not None and (emit == ZEPHYR_BOARD or emit in CORE_SCOPABLE_TARGETS):
805
+ argv += ["--core", core]
806
+
807
+ # Taken BEFORE the spawn, so a zero exit that left an already-present
808
+ # destination untouched is still refused -- see `_missing_emit_output`.
809
+ before = _output_stamp(output)
810
+ try:
811
+ out = subprocess.run(
812
+ argv,
813
+ capture_output=True,
814
+ text=True,
815
+ encoding="utf-8",
816
+ errors="replace",
817
+ stdin=subprocess.DEVNULL,
818
+ timeout=EMIT_TIMEOUT_S,
819
+ env=spawn_env(),
820
+ check=False,
821
+ )
822
+ except (OSError, ValueError, subprocess.SubprocessError) as err:
823
+ return f"Generation failed for target '{emit}': {err}"
824
+ if out.returncode == 0:
825
+ return _missing_emit_output(emit, output, before)
826
+ stderr = (out.stderr or "").strip()
827
+ return stderr or f"Generation failed for target '{emit}'."
828
+
829
+
830
+ def _emit_one_in_process(
831
+ sdk_root: Path,
832
+ board_path: Path,
833
+ emit: str,
834
+ output: Path,
835
+ core: str | None,
836
+ *,
837
+ skip_advisories: list[str] | None = None,
838
+ ) -> str | None:
839
+ """Render one relocated emit with `tan.planner` and write it. `None` on
840
+ success, else the message for its `generate.emit-failed` issue.
841
+
842
+ *skip_advisories* (tan-cli#964 review, major 6): threaded to
843
+ `planner_emit.render`, which threads it to `load_board_yaml`/
844
+ `_render_route_table`'s own schema checks. Not threaded into the
845
+ `TREE_MODES` (`zephyr-board`) branch below -- that mode writes a whole
846
+ board directory keyed on SKU+core, a separate emit shape major 6's fix
847
+ does not extend to.
848
+
849
+ The exception funnel this command's contract depends on. In a subprocess
850
+ every planner blow-up -- a malformed `board.yaml`, a missing SoM preset, an
851
+ unreadable `metadata/**`, an `OrchestratorError`, a bare `ValueError` from
852
+ the Ethos-U sizing path (I-48) -- arrived as a non-zero exit code and a
853
+ string. In-process it propagates into THIS process instead, and an escaped
854
+ traceback puts nothing parseable on stdout: the extension renders no error
855
+ at all. So the catch is broad on purpose.
856
+
857
+ `SystemExit` is caught separately because it is not an `Exception`:
858
+ `tan.planner.loader` and `kconfig_symbols` call `sys.exit()` on a missing
859
+ dependency, which in-process would tear down the CLI past every envelope
860
+ guarantee.
861
+
862
+ `zephyr-board` splits off to `render_tree`/`write_tree`: it writes a
863
+ DIRECTORY of files named per SKU+core, and `output` for that target IS that
864
+ directory (the same contract `alp_project.py --emit zephyr-board --output
865
+ <dir>` had). One `if`, inside the same funnel, so its failures are coded
866
+ exactly like every other target's.
867
+ """
868
+ try:
869
+ if emit in planner_emit.TREE_MODES:
870
+ planner_emit.write_tree(
871
+ planner_emit.render_tree(
872
+ emit, sdk_root=sdk_root, board_yaml=board_path, core=core
873
+ ),
874
+ output,
875
+ )
876
+ else:
877
+ text = planner_emit.render(
878
+ emit, sdk_root=sdk_root, board_yaml=board_path, core=core,
879
+ skip_advisories=skip_advisories,
880
+ )
881
+ planner_emit.write(text, output)
882
+ except SystemExit as err:
883
+ return (
884
+ f"Generation failed for target '{emit}': the planner exited early "
885
+ f"(code {err.code})."
886
+ )
887
+ except planner_emit.PlannerEmitError as err:
888
+ # Ours, and already phrased for a human.
889
+ return f"Generation failed for target '{emit}': {err}"
890
+ except KeyboardInterrupt:
891
+ raise
892
+ except BaseException as err: # noqa: BLE001 -- see the docstring
893
+ # tan-cli#1350: keep a `diagnostics.link: itcm` refusal's own code
894
+ # (the loop below splits it back out).
895
+ link_code = refusal_code(err)
896
+ if link_code is not None:
897
+ return f"{link_code}: {err}"
898
+ return f"Generation failed for target '{emit}': {type(err).__name__}: {err}"
899
+ return None
900
+
901
+
902
+ def _resolve_engine(
903
+ targets: tuple[str, ...], sdk_root: Path
904
+ ) -> tuple[dict[str, str], str | None]:
905
+ """`({mode: engine}, fallback_reason)` -- decided once, before any write.
906
+
907
+ Up front rather than per target so `data.engine` describes the whole run and
908
+ a half-in-process run cannot exist. `fallback_reason` is non-`None` only when
909
+ the in-process path was WANTED and refused; a deliberate
910
+ `TAN_GENERATE_EXECUTOR=subprocess` is a choice, not a fallback, and reports
911
+ itself through `data.engine` alone.
912
+ """
913
+ requested = os.environ.get(EXECUTOR_ENV, "auto").strip().lower() or "auto"
914
+ if requested not in EXECUTORS:
915
+ raise GenerateError(
916
+ "generate.invalid-executor",
917
+ f"{EXECUTOR_ENV}='{requested}' is not one of {', '.join(EXECUTORS)}.",
918
+ ExitCode.VALIDATION_FAILURE,
919
+ )
920
+
921
+ eligible = [mode for mode in targets if mode in planner_emit.IN_PROCESS_MODES]
922
+ reason: str | None = None
923
+ if requested == "subprocess":
924
+ eligible = []
925
+ elif eligible:
926
+ reason = planner_emit.unavailable(sdk_root)
927
+ if reason is not None:
928
+ if requested == "in-process":
929
+ raise GenerateError(
930
+ "generate.in-process-unavailable",
931
+ f"{EXECUTOR_ENV}=in-process was requested but the in-process "
932
+ f"planner cannot serve this run: {reason}",
933
+ ExitCode.RUNTIME_FAILURE,
934
+ )
935
+ eligible = []
936
+ return (
937
+ {
938
+ mode: "in-process" if mode in eligible else "subprocess"
939
+ for mode in targets
940
+ },
941
+ reason,
942
+ )
943
+
944
+
945
+ def _resolve_board_path(board_yaml: str | None, workspace_root: Path) -> Path:
946
+ """`--board-yaml` (absolute, or workspace-relative), else
947
+ `<workspace_root>/board.yaml`."""
948
+ if board_yaml:
949
+ candidate = Path(board_yaml)
950
+ return candidate if candidate.is_absolute() else workspace_root / candidate
951
+ return workspace_root / "board.yaml"
952
+
953
+
954
+ @dataclass(frozen=True)
955
+ class _ResolvedSdkRoot:
956
+ """`_resolve_sdk_root`'s own return shape -- a named result, not a
957
+ growing tuple (the same reasoning `tan.core.sdk_discovery.SdkRootResolution`
958
+ documents): a fact one caller needs (`foreign_global_default_for`,
959
+ tan-cli#464) must not force every field before it to be re-counted by
960
+ position.
961
+
962
+ `.path` is `None` on the SAME two conditions `SdkRootResolution.path` is
963
+ (tan-cli#900): no `--sdk-root` and nothing else resolved, or a rejected
964
+ `--sdk-root`. `.reported` is unused there -- the raise site names the raw
965
+ `sdk_root` flag itself, not this field."""
966
+
967
+ path: Path | None
968
+ tier: str
969
+ reported: str
970
+ broken_project_pin: str | None = None
971
+ foreign_global_default_for: str | None = None
972
+
973
+
974
+ def _resolve_sdk_root(sdk_root: str | None, workspace_root: Path) -> _ResolvedSdkRoot:
975
+ """The resolved checkout as a [`_ResolvedSdkRoot`] -- ALWAYS one, never a
976
+ bare `None` (tan-cli#900; the same fix tan-cli#468 made to
977
+ `presets_cmd.resolve_sdk`, applied here to this command's own WIDE ladder
978
+ instead of copying its narrow one). Collapsing to `None` the moment
979
+ nothing resolved discarded `.broken_project_pin` on that path: a
980
+ workspace whose `.alp/sdk-path` names a checkout that no longer exists,
981
+ with nothing else resolvable, reported `generate.sdk-root-unresolved`
982
+ alone -- the pin fact was never the customer's to lose. Callers now check
983
+ `.path is None`, the "unresolved" signal `SdkRootResolution.path` itself
984
+ already carries.
985
+
986
+ `--sdk-root` is TERMINAL: an explicit path that is not an SDK checkout
987
+ resolves to `.path is None` rather than falling through to discovery and
988
+ generating against a different checkout than the caller named (I-31); its
989
+ tier is always `sdkRootFlag`, matching the oracle's closed
990
+ `SdkSourceTier`.
991
+
992
+ Without `--sdk-root`: `.alp/sdk-path` project pin > the machine-global
993
+ default (`~/.alp/sdk-default`) > the WIDE positional walk
994
+ (`resolve_sdk_root_wide`, which reports its own tier) -- the wide ladder,
995
+ not the narrow one thirteen other commands take, because the oracle's
996
+ `generate` prefers the child `<ws>/alp-sdk` over a competing `../alp-sdk`
997
+ (tan-cli#263). No `ALP_SDK_ROOT` tier (tried and reverted -- see
998
+ `resolve_sdk_root_ladder`'s own docstring).
999
+
1000
+ `reported` is the string to put in the envelope's `sdk.root`: for
1001
+ `sdkRootFlag` it is the flag's raw text, NOT `str(path)` -- pathlib
1002
+ silently drops a trailing separator (`/srv/alp-sdk/` in, `/srv/alp-sdk`
1003
+ out), which the oracle does not do. Every other tier reports `str(path)`;
1004
+ those come from a pinned/discovered path, not user-typed argv, so there is
1005
+ no trailing-separator input to preserve.
1006
+ """
1007
+ if sdk_root:
1008
+ candidate = Path(sdk_root)
1009
+ if (candidate / "scripts" / "alp_project.py").is_file():
1010
+ return _ResolvedSdkRoot(candidate, "sdkRootFlag", sdk_root)
1011
+ return _ResolvedSdkRoot(None, "sdkRootFlag", sdk_root)
1012
+ resolution = resolve_sdk_root_wide(None, workspace_root)
1013
+ found = resolution.path
1014
+ return _ResolvedSdkRoot(
1015
+ found,
1016
+ resolution.tier,
1017
+ str(found) if found is not None else "",
1018
+ resolution.broken_project_pin,
1019
+ resolution.foreign_global_default_for,
1020
+ )
1021
+
1022
+
1023
+ def _finish(
1024
+ *,
1025
+ json_mode: bool,
1026
+ verbose: bool,
1027
+ quiet: bool,
1028
+ project: Project,
1029
+ targets: tuple[str, ...],
1030
+ written: list[str],
1031
+ failed: list[str],
1032
+ issues: list[Issue],
1033
+ exit_code: ExitCode,
1034
+ engine: dict[str, str] | None = None,
1035
+ sdk: SdkInfo | None = None,
1036
+ ) -> None:
1037
+ data = {
1038
+ "schemaVersion": DATA_SCHEMA_VERSION,
1039
+ "targets": list(targets),
1040
+ "written": written,
1041
+ "failed": failed,
1042
+ }
1043
+ # Present only when there is a run to describe. Every refusal resolves zero
1044
+ # targets, and `contract/envelopes/generate-board-yaml-missing` freezes that
1045
+ # shape for the Rust and Python harnesses alike -- an empty `engine: {}`
1046
+ # there would be a contract break reporting nothing.
1047
+ if engine:
1048
+ data["engine"] = engine
1049
+ if json_mode:
1050
+ emit(Envelope("generate", project, data, issues, exit_code, sdk=sdk))
1051
+ else:
1052
+ # stdout is the envelope channel in both modes; human text never
1053
+ # touches it.
1054
+ #
1055
+ # No summary line on a refusal (`targets` empty): "wrote 0/0 targets"
1056
+ # above a refusal message reads like a successful no-op run.
1057
+ #
1058
+ # `--quiet` silences the summary only -- never the issues. A CMake
1059
+ # `execute_process` shows this stderr verbatim when the emit fails, and
1060
+ # a quiet flag that swallowed the reason would leave the caller with a
1061
+ # bare `rv=1` again -- the exact defect the PLANNED `cmake/alp.cmake`
1062
+ # (unmerged, tan-cli#825) is meant to fix.
1063
+ if targets and not quiet:
1064
+ tail = f"; failed: {', '.join(failed)}" if failed else " targets"
1065
+ print(
1066
+ f"generate: wrote {len(written)}/{len(targets)}{tail}",
1067
+ file=sys.stderr,
1068
+ )
1069
+ for issue in issues:
1070
+ print(f"generate: {issue.message}", file=sys.stderr)
1071
+ if verbose:
1072
+ for target in targets:
1073
+ where = (engine or {}).get(target)
1074
+ print(
1075
+ f"target: {target}" + (f" ({where})" if where else ""),
1076
+ file=sys.stderr,
1077
+ )
1078
+ raise typer.Exit(int(exit_code))
1079
+
1080
+
1081
+ def generate(
1082
+ target: str = typer.Option(
1083
+ None,
1084
+ "--target",
1085
+ metavar="EMIT",
1086
+ help="Single generation target (e.g. zephyr-conf, dts-overlay, cmake-args).",
1087
+ ),
1088
+ all_targets: bool = typer.Option(
1089
+ False, "--all", help="Run every default generation target."
1090
+ ),
1091
+ core: str = typer.Option(
1092
+ None,
1093
+ "--core",
1094
+ metavar="CORE_ID",
1095
+ help="Core id: required by --target zephyr-board, optional scoping for some others.",
1096
+ ),
1097
+ force: bool = typer.Option(
1098
+ False, "--force", help="Allow overwriting an existing native_sim overlay."
1099
+ ),
1100
+ project: str = typer.Option(
1101
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1102
+ ),
1103
+ board_yaml: str = typer.Option(
1104
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
1105
+ ),
1106
+ sdk_root: str = typer.Option(
1107
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1108
+ ),
1109
+ output: str = typer.Option(
1110
+ None,
1111
+ "--output",
1112
+ metavar="PATH",
1113
+ help="Destination for a single --target (default: the SDK's own layout "
1114
+ "under the project root).",
1115
+ ),
1116
+ quiet: bool = typer.Option(
1117
+ False, "--quiet", help="Suppress the non-essential summary line."
1118
+ ),
1119
+ non_interactive: bool = typer.Option(
1120
+ False, "--non-interactive", hidden=True
1121
+ ),
1122
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1123
+ verbose: bool = typer.Option(
1124
+ False, "--verbose", help="List each target in text output."
1125
+ ),
1126
+ ) -> None:
1127
+ """Generate board-derived output files via the SDK's emitters."""
1128
+ # `generate` never prompts, so `--non-interactive` is accepted and ignored:
1129
+ # it exists for the PLANNED alp-sdk `cmake/alp.cmake` (unmerged,
1130
+ # tan-cli#825), which is meant to pass it on every configure (as it will
1131
+ # to every `tan` invocation), and an unknown flag there would be a Click
1132
+ # usage error that fails the whole CMake configure.
1133
+ del non_interactive
1134
+ json_mode = output_format == "json"
1135
+
1136
+ # The as-GIVEN strings, never the resolved absolute paths: the envelope
1137
+ # reflects back what the caller typed, which is what keeps the conformance
1138
+ # golden reproducible on any machine (`project.root == "."`,
1139
+ # `boardYaml == "board.yaml"`).
1140
+ #
1141
+ # tan-cli#236: NOT built via `Project.resolved` -- that would existence-check
1142
+ # this as-given string against the real cwd, which is wrong the moment
1143
+ # `--project` differs from it (the resolved `board_path` below, not this
1144
+ # string, is what must be checked). `reported` is corrected in place, right
1145
+ # below, at the one guard where the two can disagree.
1146
+ reported = Project(
1147
+ root=project if project else ".",
1148
+ board_yaml=board_yaml if board_yaml else "board.yaml",
1149
+ )
1150
+
1151
+ def finish(**kwargs) -> None:
1152
+ _finish(
1153
+ json_mode=json_mode,
1154
+ verbose=verbose,
1155
+ quiet=quiet,
1156
+ project=reported,
1157
+ sdk=sdk_info,
1158
+ **kwargs,
1159
+ )
1160
+
1161
+ def refuse(err: GenerateError) -> None:
1162
+ # No `engine`: a refusal resolved no targets, so there is no run to
1163
+ # describe (and the committed conformance golden pins that data shape).
1164
+ # `sdk` still reports (via `finish`'s default) once it is resolved --
1165
+ # only a refusal that precedes/IS the SDK resolution itself leaves it
1166
+ # unset, matching the oracle (`generate.board-yaml-missing` and
1167
+ # `generate.sdk-root-unresolved` both carry no `sdk`; every guard past
1168
+ # a successful resolution does). `err.extra_issues` PREPENDED
1169
+ # (tan-cli#900), the `[*resolution_issues, Issue(...)]` shape
1170
+ # `clean_cmd._run` already uses.
1171
+ finish(
1172
+ targets=(),
1173
+ written=[],
1174
+ failed=[],
1175
+ issues=[*(err.extra_issues or []), Issue(err.code, "error", err.message)],
1176
+ exit_code=err.exit_code,
1177
+ )
1178
+
1179
+ engine: dict[str, str] = {}
1180
+ sdk_info: SdkInfo | None = None
1181
+ try:
1182
+ workspace_root = Path(os.path.abspath(project)) if project else Path.cwd()
1183
+ board_path = _resolve_board_path(board_yaml, workspace_root)
1184
+
1185
+ if not board_path.exists():
1186
+ # tan-cli#236: the refusal below says the file does not exist, so
1187
+ # the SAME envelope's `project.boardYaml` must not still name it
1188
+ # (Rust's own starkest instance of this bug -- see
1189
+ # `crates/tan-cli/src/commands/generate.rs`).
1190
+ reported = Project(root=reported.root, board_yaml=None)
1191
+ raise GenerateError(
1192
+ "generate.board-yaml-missing",
1193
+ "board.yaml path could not be resolved or the file does not exist.",
1194
+ ExitCode.VALIDATION_FAILURE,
1195
+ )
1196
+
1197
+ resolved = _resolve_sdk_root(sdk_root, workspace_root)
1198
+ if resolved.path is None:
1199
+ # tan-cli#900: `resolved.broken_project_pin` survives even on
1200
+ # this refusal now, so a gone `.alp/sdk-path` target still
1201
+ # discloses instead of looking like no pin was ever set.
1202
+ pin_issue = project_pin_issue(resolved.broken_project_pin, resolved.tier)
1203
+ foreign_issue = global_default_foreign_project_issue(
1204
+ resolved.foreign_global_default_for
1205
+ )
1206
+ raise GenerateError(
1207
+ "generate.sdk-root-unresolved",
1208
+ # tan-cli#497 defect 7: a REJECTED `--sdk-root` names the
1209
+ # value. This refusal carries NO `sdk` block (see `refuse`'s
1210
+ # own comment above -- `sdk_info` is still `None` here), so
1211
+ # before this the typed path appeared nowhere in the envelope
1212
+ # at all, and the message opened by recommending the very flag
1213
+ # it had just rejected.
1214
+ rejected_sdk_root_message(sdk_root, "Nothing was generated.")
1215
+ if sdk_root
1216
+ # `tan sdk switch` refuses in this build (tan-cli#305) -- kept
1217
+ # the two mechanisms that actually work here (`--sdk-root`,
1218
+ # placing the project near a checkout) and swapped the third
1219
+ # for NO_SDK_NEXT_STEPS's honest "how to get one at all".
1220
+ else with_sdk_search(
1221
+ "alp-sdk root is unresolved. Use --sdk-root, place the project near an "
1222
+ f"alp-sdk checkout, or {NO_SDK_NEXT_STEPS}.",
1223
+ workspace_root,
1224
+ ),
1225
+ ExitCode.VALIDATION_FAILURE,
1226
+ extra_issues=[i for i in (pin_issue, foreign_issue) if i is not None],
1227
+ )
1228
+ resolved_sdk = resolved.path
1229
+ sdk_tier = resolved.tier
1230
+ sdk_reported = resolved.reported
1231
+ sdk_broken_pin = resolved.broken_project_pin
1232
+ sdk_foreign_default = resolved.foreign_global_default_for
1233
+ # Set only now: every guard from here on runs with a resolved SDK, so
1234
+ # the envelope carries `sdk` on both success and a later refusal
1235
+ # (`--target`/`--core` mistakes, an overlay guard, ...), matching the
1236
+ # oracle. The two guards above (`board.yaml` missing, this one) raise
1237
+ # before `sdk_info` exists, so their envelopes carry no `sdk` --
1238
+ # exactly the oracle's shape for both.
1239
+ sdk_info = SdkInfo(sdk_reported, sdk_tier)
1240
+
1241
+ # tan-cli#963: computed here, and seeded into `issues` before any
1242
+ # other entry, so a resolvable-but-broken pin (or a foreign global
1243
+ # default) reads FIRST on this path too -- matching both `refuse`'s
1244
+ # `[*extra_issues, Issue(...)]` shape above and `clean_cmd._run`'s
1245
+ # convention. These are resolution advisories (the run may have
1246
+ # consulted the wrong checkout entirely); the per-target
1247
+ # `generate.emit-failed` entries and `generate.in-process-unavailable`
1248
+ # below are outcome/context and stay appended, in place.
1249
+ pin_issue = project_pin_issue(sdk_broken_pin, sdk_tier)
1250
+ # tan-cli#464: same silence, one tier down -- a `globalDefault` answer
1251
+ # a DIFFERENT project's bootstrap relocation actually decided.
1252
+ foreign_issue = global_default_foreign_project_issue(sdk_foreign_default)
1253
+ issues: list[Issue] = [i for i in (pin_issue, foreign_issue) if i is not None]
1254
+
1255
+ targets = resolve_targets(target, all_targets, core)
1256
+
1257
+ # Which engine runs what -- before any guard that writes, so a bad
1258
+ # `TAN_GENERATE_EXECUTOR` refuses without leaving a directory behind.
1259
+ engine, fallback_reason = _resolve_engine(targets, resolved_sdk)
1260
+
1261
+ # zephyr-board always resolves alone, so its directory name is derived
1262
+ # once here rather than inside the emit loop.
1263
+ board_dir_name: str | None = None
1264
+ if ZEPHYR_BOARD in targets:
1265
+ sku = _board_sku(board_path)
1266
+ board_dir_name = (
1267
+ zephyr_board_dir_name(sku, core) if sku and core else None
1268
+ )
1269
+ if board_dir_name is None:
1270
+ raise GenerateError(
1271
+ "generate.board-sku-unresolved",
1272
+ "board.yaml's som.sku is missing or is not an E1M-* SKU; "
1273
+ "`--target zephyr-board` needs it to name the generated board "
1274
+ "directory (alp-sdk's `alp_e1m_<sku-slug>_<core>` convention).",
1275
+ ExitCode.VALIDATION_FAILURE,
1276
+ )
1277
+
1278
+ output_override = (
1279
+ _resolve_output_override(output, targets, workspace_root)
1280
+ if output
1281
+ else None
1282
+ )
1283
+
1284
+ # tan-cli#501: an EXPLICIT `--target native-sim-overlay` still refuses
1285
+ # a foreign (bannerless) overlay without `--force` -- the user named
1286
+ # this file. But `native-sim-overlay` reaching `targets` only via the
1287
+ # bare/`--all` default set is different (a vendored scaffold ships one,
1288
+ # tan-cli#457 protects a hand edit): refusing the other eight targets
1289
+ # over it is wrong, and a generic `--force` truncating it is worse
1290
+ # (measured). So an implicit inclusion is left alone: dropped, reported.
1291
+ overlay_explicit = targets == ("native-sim-overlay",)
1292
+ if _overlay_would_overwrite(workspace_root, targets, False, output_override):
1293
+ if overlay_explicit:
1294
+ if not force:
1295
+ raise GenerateError(
1296
+ "generate.would-overwrite",
1297
+ "boards/native_sim_native_64.overlay already exists. Use "
1298
+ "--force to overwrite.",
1299
+ ExitCode.WRITE_FAILURE,
1300
+ )
1301
+ # else: explicit + --force -- fall through, write loop overwrites.
1302
+ else:
1303
+ targets = tuple(t for t in targets if t != "native-sim-overlay")
1304
+ # #3: keep `data.engine` in step with the drop (computed above).
1305
+ engine = {k: v for k, v in engine.items() if k != "native-sim-overlay"}
1306
+ # #2: may be a template's OWN vendored overlay (tan cannot
1307
+ # tell) -- the old `--force` remedy DESTROYED one (measured);
1308
+ # no clobber offered, an explicit `--target ... --force` still works.
1309
+ issues.append(
1310
+ Issue(
1311
+ "generate.overlay-not-owned",
1312
+ "warning",
1313
+ "boards/native_sim_native_64.overlay already exists and was "
1314
+ "not generated by tan -- this project's own file (a hand "
1315
+ "edit, or a template's vendored overlay); left untouched.",
1316
+ )
1317
+ )
1318
+
1319
+ # LAST of the guards, because it is the only one that WRITES: it creates
1320
+ # the parent directory (and, for `zephyr-board`, the output directory
1321
+ # itself). Running it earlier would leave strays behind for a run the
1322
+ # `som.sku` or overlay guard above was about to refuse.
1323
+ probe_created_output = False
1324
+ if output_override is not None:
1325
+ probe_created_output = _ensure_writable(output_override, targets[0])
1326
+ else:
1327
+ # No `--output`: every target writes its DEFAULT path, which must
1328
+ # stay confined to the project after symlink resolution
1329
+ # (tan-cli#325). Checked for every target up front, so a run with
1330
+ # several targets refuses before the first one writes rather than
1331
+ # leaving an earlier target's file on disk.
1332
+ _confine_default_outputs(workspace_root, targets, board_dir_name)
1333
+
1334
+ # Only the spawned engine cares which interpreter PATH offers -- the
1335
+ # in-process one IS the interpreter. Probing regardless would refuse a
1336
+ # fully in-process run on a host whose `python` is old and unused, and
1337
+ # cost it a subprocess to find out.
1338
+ python = ""
1339
+ if "subprocess" in engine.values():
1340
+ python = _planner_python(
1341
+ str(workspace_root), str(resolved_sdk) if resolved_sdk else None
1342
+ )
1343
+ floor, _floor_source = resolve_manifest_python_floor(str(resolved_sdk))
1344
+ if (too_old := _python_too_old(python, floor)) is not None:
1345
+ raise GenerateError(
1346
+ "generate.python-too-old", too_old, ExitCode.RUNTIME_FAILURE
1347
+ )
1348
+
1349
+ script = resolved_sdk / "scripts" / "alp_project.py"
1350
+ written: list[str] = []
1351
+ failed: list[str] = []
1352
+ link_refused = False
1353
+ # tan-cli#964 review (major 6): one shared collector across every
1354
+ # in-process target this run renders, so a schema absent for the
1355
+ # whole checkout is disclosed ONCE (deduplicated below), not once per
1356
+ # target that happens to read it.
1357
+ schema_skip_advisories: list[str] = []
1358
+ for mode in targets:
1359
+ target_output = output_override or _output_path(
1360
+ workspace_root, mode, board_dir_name
1361
+ )
1362
+ if engine[mode] == "in-process":
1363
+ message = _emit_one_in_process(
1364
+ resolved_sdk, board_path, mode, target_output, core,
1365
+ skip_advisories=schema_skip_advisories,
1366
+ )
1367
+ else:
1368
+ message = _emit_one(
1369
+ python, script, board_path, mode, target_output, core
1370
+ )
1371
+ if message is None:
1372
+ written.append(_relative_or_full(workspace_root, target_output))
1373
+ else:
1374
+ failed.append(mode)
1375
+ link_code, message = split_coded_message(message)
1376
+ if link_code is not None:
1377
+ link_refused = True
1378
+ issues.append(
1379
+ Issue(link_code or "generate.emit-failed", "error", message))
1380
+ # tan-cli#420: don't leave the writability probe's own empty
1381
+ # file behind for a Zephyr configure to pick up as a valid
1382
+ # (and silently empty) EXTRA_CONF_FILE. Only the file this
1383
+ # run created, and only while it is still zero-byte.
1384
+ if probe_created_output and target_output == output_override:
1385
+ _discard_probe_file(target_output)
1386
+
1387
+ # tan-cli#964 review (major 6): "skip-but-disclose". One issue per
1388
+ # DISTINCT note (`dict.fromkeys` dedups while keeping first-seen
1389
+ # order) -- a run with several targets all missing the same schema
1390
+ # must not repeat the identical line once per target. `info`, not
1391
+ # `warning`: nothing here is wrong with the DOCUMENT, only with tan's
1392
+ # ability to check it, matching `presets`/`size`/`debug-config`'s own
1393
+ # severity split between `*.metadata-schema-invalid` (warning, a real
1394
+ # violation) and this code (info, nothing was checked).
1395
+ for note in dict.fromkeys(schema_skip_advisories):
1396
+ issues.append(Issue("generate.metadata-schema-unchecked", "info", note))
1397
+
1398
+ # APPENDED, after the per-target issues: what the user asked for comes
1399
+ # first and this is context about how it ran. Never omitted, though --
1400
+ # a fallback nobody can see is how the port got believed before it
1401
+ # happened.
1402
+ if fallback_reason is not None:
1403
+ issues.append(
1404
+ Issue(
1405
+ "generate.in-process-unavailable",
1406
+ "warning",
1407
+ "generated by spawning the SDK's own emitter instead of the "
1408
+ f"in-process planner: {fallback_reason}",
1409
+ )
1410
+ )
1411
+ # tan-cli#407: this command just wrote `build/generated/alp.conf`, the
1412
+ # DTS overlays and `alp_hw_info_build.h` out of one checkout's
1413
+ # `metadata/`, and `tan build` may plan the very same tree against
1414
+ # another -- both reporting `sourceTier: "discovery"`. Of the four wide
1415
+ # commands this is the one where the split becomes FILES on disk, so
1416
+ # the warning ships beside them. `sdk_root` is the RAW flag (never
1417
+ # reassigned in this function): an explicit root puts both ladders on
1418
+ # the terminal `sdkRootFlag` tier and the helper returns `None`.
1419
+ divergence = sdk_ladder_divergence_issue(sdk_root, workspace_root, wide=True)
1420
+ if divergence is not None:
1421
+ issues.append(divergence)
1422
+ except GenerateError as err:
1423
+ refuse(err)
1424
+ return
1425
+ except Exception as err: # noqa: BLE001 -- the backstop; see the module docstring
1426
+ # `typer.Exit` cannot reach here: it is raised only from `_finish`,
1427
+ # which runs outside this try.
1428
+ refuse(
1429
+ GenerateError(
1430
+ "generate.internal-failure",
1431
+ f"generate failed unexpectedly: {err.__class__.__name__}: {err}",
1432
+ ExitCode.INTERNAL_FAILURE,
1433
+ )
1434
+ )
1435
+ return
1436
+
1437
+ finish(
1438
+ targets=targets,
1439
+ written=written,
1440
+ failed=failed,
1441
+ issues=issues,
1442
+ engine=engine,
1443
+ exit_code=(
1444
+ ExitCode.SUCCESS if not failed
1445
+ else ExitCode.VALIDATION_FAILURE if link_refused
1446
+ else ExitCode.WRITE_FAILURE
1447
+ ),
1448
+ )
1449
+
1450
+
1451
+ # tan-cli#261: adds the two oracle `GlobalArgs` flags this command was still
1452
+ # missing (`--ci`/`--no-color`) on top of the five already declared above
1453
+ # (`--target`/`--all`/`--quiet`/`--verbose` read for real; `--non-interactive`
1454
+ # accepted and dropped the same way these two now are); see
1455
+ # `tan.core.global_flags`.
1456
+ generate = accept_global_flags(generate)