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,1319 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan explain` -- describe a project template, a module template, or a
3
+ generation target. tan's teaching surface: the only command whose OUTPUT is the
4
+ product, so its wording is contract in the same way an envelope key is.
5
+
6
+ Mirrors `crates/tan-cli/src/commands/explain.rs` plus the two `tan-core`
7
+ registries it reads (`wizard/service/registry.rs`'s template definitions,
8
+ `loader.rs`'s `GENERATION_TARGET_CATALOG`). `--template` explains an
9
+ init/scaffold template, `--target` a generation output target, neither prints an
10
+ overview, and BOTH is an error -- exit 1, like an unknown id.
11
+
12
+ **Why the catalogue is vendored here and not read from the SDK.** Weighed
13
+ against `alp-sdk/docs/superpowers/specs/2026-07-29-tan-port-invariants.md`:
14
+
15
+ * **I-32** is the governing one, and it is not close. `tan explain --template`
16
+ describes what `tan init` will write, and `tan init` is SDK-FREE by design --
17
+ it reads the vendored `--emit scaffold` tree "without ever shelling the SDK"
18
+ so a fresh customer's first command works with no alp-sdk checkout anywhere.
19
+ A description sourced from a checkout would go silent (or lie) on exactly the
20
+ machine `tan init` is guaranteed to work on. Anti-pattern #22 in that doc names
21
+ shelling the SDK for this content specifically.
22
+ * **I-26 / ADR-0017** (`metadata/**` stays in alp-sdk; tan learns no hardware
23
+ fact) is what would otherwise pull the other way, as it does for
24
+ `tan examples`. It does not apply: this prose is tan's own description of
25
+ tan's own commands and tan's own emit outputs -- there is no `metadata/`
26
+ source for "Minimal template keeps generated code intentionally small". The
27
+ part-numbers inside the prose (TMP112, BME280, CC3501E, the two SKUs) enter no
28
+ decision here; they are transcribed English, and every one already ships in
29
+ tan via the vendored scaffold trees and `core/scaffold.py`'s
30
+ `DEFAULT_SOM_SKU`/`TEMPLATE_SUPPORTED_SKUS`. Nothing in this file is keyed
31
+ on a SKU, an address, or a pin.
32
+
33
+ The ONE derived line is "Default libraries", which reads each template's
34
+ vendored `board.yaml` rather than a registry field
35
+ (`scaffold.vendored_library_names_for`) -- tan-cli#124: the field is
36
+ deliberately blanked for a vendored template and reported "(none)" for
37
+ `edge-ai-starter`, whose scaffold declares `tflite-micro`.
38
+
39
+ **`data.som` (tan-cli#866): what `tan init` will accept/refuse for this
40
+ template, structured, not just in prose.** A project template's `--template`
41
+ hit carries `data.som.{initAcceptsSkus,initRefusesSkuPrefixes}`, computed by
42
+ `_som_support_data` from `scaffold.TEMPLATE_SUPPORTED_SKUS` /
43
+ `scaffold.UNSUPPORTED_SOM_FAMILY_PREFIXES` -- the SAME two tables `tan init`
44
+ already gates `init.invalid-som` / `init.som-unsupported` on, READ here
45
+ rather than retyped.
46
+
47
+ **These are `tan init` REFUSAL predictions, not a capability statement --
48
+ name them accordingly and do not rename them back.** (PR #985 review, major
49
+ 1.) `TEMPLATE_SUPPORTED_SKUS` is exactly, and only, the set `init_cmd` checks
50
+ before writing a tree; it says nothing about which SKUs alp-sdk's own
51
+ scaffold catalog actually validates a template against. Measured: for every
52
+ template here except `iot-starter`/`multicore-mailbox`, alp-sdk's catalog
53
+ `supported.som_skus` is the narrow `["E1M-AEN801", "E1M-V2N101"]`
54
+ (`tan/templates/vendored/MANIFEST.md`), while `initRefusesSkuPrefixes` here
55
+ publishes `[]` for those same four (no family is unvendored today) -- i.e.
56
+ `tan init` refuses none of them, where the catalog validates 2. That is not a
57
+ bug in this field: tan is SDK-free by design (I-32 above) and `_family_bucket`
58
+ (`tan.core.scaffold`) deliberately falls an unrecognised SKU prefix onto the
59
+ default (Alif) tree rather than refusing it, so `tan init --template
60
+ sensor-starter --som E1M-AEN301` really does exit 0 today. A field NAMED
61
+ "supported" would be lying about capability for those 4 of 7 templates; a
62
+ field that says "what tan init will accept" is exactly true, including for
63
+ a SKU that does not exist (`E1M-ZZZ999` also predicts `ok`, correctly, by
64
+ this policy). `python/tests/gates/test_no_new_hardware_facts.py` already
65
+ tracks the underlying gap as its largest acknowledged debt item, retiring
66
+ only once the SDK catalog's own per-template family mapping is readable from
67
+ `metadata/**` -- that debt is orthogonal to and not resolved by this field;
68
+ do not read `initAcceptsSkus`/`initRefusesSkuPrefixes` as "the SDK says this
69
+ combination works".
70
+
71
+ Before this PR, the only place a template's SoM restriction was written down
72
+ was inside `details[]` prose ("... (E1M-AEN801 only)"), which a consumer
73
+ would have had to parse and which could silently drift from what `tan init`
74
+ actually enforces -- #866 is named for exactly that string. The `iot-starter`
75
+ description's own parenthetical, and its explanation's Wi-Fi-transport
76
+ sentence, are now GENERATED from the same data (`_only_note`, `_iot_wifi_note`),
77
+ so neither can disagree with it by construction; see
78
+ `tests/commands/test_explain_command.py`'s
79
+ `test_every_sku_mentioned_in_template_prose_matches_its_structured_som_data`
80
+ for the drift gate over every OTHER SKU mention in project-template prose
81
+ (`multicore-mailbox`'s explanation, the one sentence this change did not also
82
+ make generated). Scoped to PROJECT templates only -- module
83
+ templates and generation targets carry no SoM concept, so `data.som` is
84
+ absent (not `null`) on those two selector kinds, the same absent-vs-null
85
+ convention `--code`'s `data.diagnostic`/`data.suggestions` already use.
86
+
87
+ **Every failure is a coded issue, never a traceback.** Ambiguous selector,
88
+ unknown template, unknown target, and an unreadable vendored tree each map to an
89
+ `explain.*` code and exit 1/5; the catch-all at the bottom is the backstop. This
90
+ command spawns no subprocess and opens at most one file (a vendored board.yaml,
91
+ only for `--template`), so there is nothing to time out. `available` is emitted
92
+ on the failure path too -- deliberately, so a caller that guessed wrong gets the
93
+ valid ids in the same envelope.
94
+
95
+ NOT PORTED, and honest about it: `--quiet` (Rust suppresses the `- <detail>`
96
+ lines with it). The Python port carries no `--quiet` on any command, and one
97
+ command-local copy would be a worse surface than a clean refusal -- passing it
98
+ is a Click usage error naming the unknown flag.
99
+
100
+ **`--code` is a THIRD selector, and the one mode that reads a checkout**
101
+ (ADR-0020 end-state B). `tan explain --code ALP-B003` resolves an
102
+ alp-sdk diagnostic / `ALP_ERR_*` code out of the bound checkout's
103
+ `metadata/error-catalog.json` -- the lookup half of alp-sdk's
104
+ `scripts/alp_cli/explain.py`, whose retirement this enables. A FLAG on this
105
+ verb, not a new command: alp-sdk spells it `alp explain <code>` too, so the
106
+ muscle memory transfers and the ambiguity guard already here extends to it.
107
+ Not the positional, which already means TEMPLATE -- sniffing "does this
108
+ argument look like a code?" is exactly the ambiguous-input class
109
+ `explain.positional-template-conflict` refuses.
110
+
111
+ The other three paths stay BYTE-IDENTICAL, deliberately: `--code` adds no
112
+ line to the overview and no key to `data.available` (the `explain-overview`
113
+ golden pins both, and `--help` already lists the flag), and
114
+ `data.diagnostic`/`data.suggestions` appear on the `--code` path ALONE -- the
115
+ extension reads a `data` key it does not find with a `?? []` fallback, so an
116
+ absent key is the honest shape for a mode that did not run. I-32 therefore
117
+ still holds where it was measured to matter: with no alp-sdk anywhere,
118
+ `tan init`'s templates are still explainable. With no checkout resolvable
119
+ `--code` REFUSES (`explain.sdk-root-unresolved`) rather than answering from a
120
+ vendored copy -- the code list is alp-sdk's fact (I-26/ADR-0017), and a baked
121
+ copy would answer confidently out of date.
122
+
123
+ DIVERGENCE from `alp_cli/explain.py`, measured by running both against the same
124
+ alp-sdk `origin/dev` worktree, and each one deliberate
125
+ (`faultdecode_cmd.py:14-26` is the precedent for documenting it here):
126
+
127
+ * **Field VALUES are verbatim; the frame around them is tan's.** `explain.py`
128
+ writes `<CODE> (<kind>)` with two spaces then ` <label>: <value>` per
129
+ field; this writes `explain: <CODE> (<kind>)` then `- <label>: <value>`,
130
+ because that is the ONE shape every `explain` selector renders in and a
131
+ per-mode renderer would make two commands out of one. Same fields, same
132
+ order, same omissions, same text.
133
+ * **stderr, not stdout.** `explain.py` `click.echo`s to stdout; every `tan`
134
+ command's human text goes to stderr because stdout is the envelope channel.
135
+ * **`--format json` wraps; `explain.py --json` does not.** The raw entry
136
+ `explain.py` printed as its whole document is `data.diagnostic` here,
137
+ byte-for-byte, so the payload survives the wrapping intact -- the same
138
+ divergence, for the same reason, as `tan faultdecode --format json`.
139
+ * **NOT PORTED: `--no-color`.** `explain.py` paints the header cyan and each
140
+ label yellow; `tan explain` has never coloured any selector's output, so
141
+ there is nothing for a `--no-color` to turn off. The flag still PARSES (it
142
+ is one of the ten global flags) and is simply inert here, as on every other
143
+ `tan` command that emits no colour.
144
+ * **The `doc:` value stays repo-relative** (`docs/diagnostics/ALP-B003.md`),
145
+ exactly as the catalogue holds it. Joining it onto the resolved checkout
146
+ would be a value alp-sdk never wrote; `sdk.root` in the same envelope is
147
+ what a consumer joins it against.
148
+ * **The no-near-miss sentence is tan's own wording, not `explain.py`'s.**
149
+ `explain.py` prints ``unknown code 'ZZZ' -- run `alp explain` against a
150
+ code from metadata/error-catalog.json`` -- its own binary name and
151
+ subcommand, which cannot be repeated verbatim from a `tan`-only install;
152
+ this prints `` explain: unknown code 'ZZZ' -- pass a code from the SDK's
153
+ metadata/error-catalog.json.`` instead (`_unknown_code_line`). Only the
154
+ WITH-SUGGESTIONS miss sentence is verbatim from `explain.py`.
155
+ * **The whole JSON MISS document, not just the hit path.** `alp_cli explain
156
+ <bad> --json` prints `{"error": "unknown-code", "code": ..., "suggestions":
157
+ [...]}` as its ENTIRE stdout document for a miss. `tan explain --code <bad>
158
+ --format json` wraps instead, the same as the hit path: `data.diagnostic`
159
+ is `null` (never the bare `"error"` shape), `data.suggestions` carries the
160
+ shortlist, and the reason lives in `issues[0]` (`explain.code-unknown`) --
161
+ not folded into `data` the way `explain.py`'s single-document miss is.
162
+ """
163
+
164
+ from __future__ import annotations
165
+
166
+ import os
167
+ import re
168
+ import sys
169
+ from dataclasses import dataclass, field
170
+ from pathlib import Path
171
+
172
+ import typer
173
+
174
+ from tan.core import error_catalog
175
+ from tan.core.global_flags import accept_global_flags
176
+ from tan.core.scaffold import (
177
+ TEMPLATE_SUPPORTED_SKUS,
178
+ UNSUPPORTED_SOM_FAMILY_PREFIXES,
179
+ TemplateDataError,
180
+ is_family_gated,
181
+ vendored_library_names_for,
182
+ )
183
+ from tan.core.text_layout import wrap_lines
184
+ from tan.env import wrap_width
185
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
186
+ from tan.exit_codes import ExitCode
187
+ from tan.output_format import FORMAT_HELP, OutputFormat
188
+
189
+ #: `data.schemaVersion` for this command's payload.
190
+ DATA_SCHEMA_VERSION = "1"
191
+
192
+ #: An unrecognised `[TEMPLATE]`/`--template` value shaped like a diagnostic
193
+ #: code (`ALP-B003`, `ALP_ERR_NO_BACKEND`) rather than a template id. The
194
+ #: overview deliberately never lists `--code` (it would break the golden), so
195
+ #: `--help` was the ONLY way to discover it -- a caller who typed the
196
+ #: alp-sdk spelling minus the flag got "unknown template" with no pointer at
197
+ #: the flag that would have worked. Anchored (not a bare `search`) so an id
198
+ #: that merely CONTAINS one of these shapes mid-string doesn't false-positive.
199
+ _LOOKS_LIKE_A_DIAGNOSTIC_CODE = re.compile(r"^(ALP-B\d+|ALP_ERR_.+)$", re.IGNORECASE)
200
+
201
+
202
+ def _code_hint(value: str) -> str:
203
+ """The extra sentence appended to `explain.template-unknown`'s text line
204
+ when `value` is shaped like a diagnostic code -- empty string otherwise,
205
+ so every other rejected template keeps its existing, golden-pinned
206
+ wording verbatim."""
207
+ if _LOOKS_LIKE_A_DIAGNOSTIC_CODE.match(value):
208
+ return f" Looking for a diagnostic code? Use --code {value} instead."
209
+ return ""
210
+
211
+
212
+ def _only_note(skus: tuple[str, ...] | None) -> str:
213
+ """` (<SKU>[, <SKU>...] only)`, or `""` when `skus` is falsy -- the
214
+ description's trailing SoM qualifier, DERIVED from `TEMPLATE_SUPPORTED_SKUS`
215
+ (`tan.core.scaffold`) rather than a hand-typed SKU string in the registry
216
+ below. tan-cli#866: a hand-typed "(E1M-AEN801 only)" is exactly the second
217
+ source of truth the issue is about -- it can drift from the data the CLI
218
+ actually gates `tan init` on, silently, the way a copy-edit here never
219
+ would if a consumer parsed the sentence instead of `data.som`. Called
220
+ at MODULE LOAD, building `PROJECT_TEMPLATES` below -- must stay defined
221
+ ahead of that tuple."""
222
+ if not skus:
223
+ return ""
224
+ return f" ({', '.join(skus)} only)"
225
+
226
+
227
+ def _iot_wifi_note(skus: tuple[str, ...] | None) -> str:
228
+ """`iot-starter`'s explanation sentence about the CC3501E Wi-Fi transport,
229
+ with its SUPPORTED-SKU mentions (both of them) DERIVED from
230
+ `TEMPLATE_SUPPORTED_SKUS["iot-starter"]` rather than hand-typed a second
231
+ time. PR #985 review, major 3: #866 names this sentence, verbatim, as one
232
+ of the two prose carriers of this restriction the issue is about; before
233
+ this it was hand-typed independently of `_only_note`'s parenthetical, so
234
+ a hand-edit of only its trailing SKU passed the whole suite (mutant E in
235
+ that review). `E1M-V2N101` in the middle clause stays hand-typed: it is
236
+ not a claim of support (the opposite -- the Wi-Fi path that does NOT
237
+ exist there yet) and carries no table this module owns; it is the one
238
+ named entry in `test_explain_command.py`'s `_CONTRAST_MENTIONS`, so a
239
+ future rewrite that changes which SKU is contrasted still has to update
240
+ that gate on purpose rather than by accident.
241
+
242
+ `skus` empty/`None` is unreachable in practice (`iot-starter` is always a
243
+ key of `TEMPLATE_SUPPORTED_SKUS`) but degrades to a SKU-free form rather
244
+ than raising, matching `_only_note`'s style."""
245
+ if not skus:
246
+ return (
247
+ "AEN-only + preview: the CC3501E Wi-Fi transport is "
248
+ "silicon-validated on the supported SKU only; the Wi-Fi path on "
249
+ "any other SoM does not exist yet, so --som is fixed for this "
250
+ "template."
251
+ )
252
+ sku = skus[0]
253
+ return (
254
+ f"AEN-only + preview: {sku}'s CC3501E Wi-Fi transport is "
255
+ f"silicon-validated; the E1M-V2N101 Wi-Fi path does not exist yet, "
256
+ f"so --som is fixed to {sku} for this template."
257
+ )
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class ProjectTemplate:
262
+ """A `tan init` template as `tan explain` describes it.
263
+
264
+ `features` is a `(wifi, mqtt, ble, tls)` tuple defaulting to all-false,
265
+ collapsing Rust's `Option<WizardFeatureFlags>`: `format_feature_flags`
266
+ renders `None` as all-false, so the distinction is unobservable in output
267
+ and modelling it would only add a branch.
268
+ """
269
+
270
+ id: str
271
+ label: str
272
+ description: str
273
+ explanation: tuple[str, ...]
274
+ features: tuple[bool, bool, bool, bool] = (False, False, False, False)
275
+ #: The registry's own `libs`, read ONLY for `minimal-app` -- every other
276
+ #: template's libraries come from its vendored board.yaml (tan-cli#124).
277
+ libs: tuple[str, ...] = ()
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class ModuleTemplate:
282
+ """A `tan scaffold` module template. No `explanation`: Rust's
283
+ `module_template_details` reads description + function_prefix + a fixed
284
+ hint line and nothing else, so the registry's per-module explanation
285
+ strings are unreachable from this command."""
286
+
287
+ id: str
288
+ label: str
289
+ description: str
290
+ function_prefix: str
291
+
292
+
293
+ @dataclass(frozen=True)
294
+ class GenerationTarget:
295
+ """One `tan generate --target <emit>` output, verbatim from
296
+ `GENERATION_TARGET_CATALOG` (`crates/tan-core/src/loader.rs`)."""
297
+
298
+ emit: str
299
+ display_name: str
300
+ output_relative_path: str
301
+ preview_label: str
302
+ preview_language_id: str
303
+ #: True when `output_relative_path` is a documentary template for a
304
+ #: per-SKU/core DIRECTORY, not a literal path (only `zephyr-board`).
305
+ is_directory: bool = False
306
+
307
+
308
+ #: The project-wizard templates, in registry order -- `TEMPLATE_DEFINITIONS`
309
+ #: (`crates/tan-core/src/wizard/service/registry.rs`). Order is wire contract:
310
+ #: `data.available.projectTemplates` and the overview's "Project templates:"
311
+ #: line both report it, and `contract/envelopes/explain-overview` pins both.
312
+ PROJECT_TEMPLATES: tuple[ProjectTemplate, ...] = (
313
+ ProjectTemplate(
314
+ id="minimal-app",
315
+ label="Minimal app",
316
+ description="Smallest baseline project with a simple main loop.",
317
+ explanation=(
318
+ "Minimal template keeps generated code intentionally small and neutral.",
319
+ "Use this baseline when you want full control over feature bring-up order.",
320
+ ),
321
+ ),
322
+ ProjectTemplate(
323
+ id="zephyr-app",
324
+ label="Zephyr app",
325
+ description=(
326
+ "West-buildable Zephyr application wired to board.yaml via the SDK loader."
327
+ ),
328
+ explanation=(
329
+ "Real Zephyr app vendored from the SDK's `minimal` scaffold: "
330
+ "find_package(Zephyr) + board.yaml -> alp.conf via EXTRA_CONF_FILE.",
331
+ "Build with `west build -b <board>` after `export ALP_SDK_ROOT=<your "
332
+ "alp-sdk checkout>`.",
333
+ ),
334
+ ),
335
+ ProjectTemplate(
336
+ id="sensor-starter",
337
+ label="Sensor starter",
338
+ description=(
339
+ "West-buildable TMP112 i2c-master app wired to board.yaml via the SDK loader."
340
+ ),
341
+ explanation=(
342
+ "Real Zephyr app vendored from the SDK's `sensor` scaffold: reads the "
343
+ "TMP112 temperature sensor via <alp/chips/tmp112.h> on BRD_I2C.",
344
+ "Build with `west build -b <board>` after `export ALP_SDK_ROOT=<your "
345
+ "alp-sdk checkout>`.",
346
+ ),
347
+ ),
348
+ ProjectTemplate(
349
+ id="iot-starter",
350
+ label="IoT starter",
351
+ description=(
352
+ "West-buildable Wi-Fi + MQTT/TLS telemetry app wired to board.yaml via "
353
+ "the SDK loader"
354
+ + _only_note(TEMPLATE_SUPPORTED_SKUS.get("iot-starter"))
355
+ + "."
356
+ ),
357
+ explanation=(
358
+ "Real Zephyr app vendored from the SDK's `iot` scaffold: brings up Wi-Fi "
359
+ "via the CC3501E bridge and publishes an mqtts:// (TLS) MQTT telemetry "
360
+ "reading on a cadence, through the portable <alp/iot.h> surface.",
361
+ _iot_wifi_note(TEMPLATE_SUPPORTED_SKUS.get("iot-starter")),
362
+ "Build with `west build -b <board>` after `export ALP_SDK_ROOT=<your "
363
+ "alp-sdk checkout>`.",
364
+ ),
365
+ # The ONE template whose flags are not blanked in the Rust registry:
366
+ # "Default features" still reads them, and the vendored board.yaml
367
+ # cannot supply `mqtt` (there is no `iot.mqtt` toggle -- MQTT is
368
+ # inherent to this app). `mqtt=false` here would contradict the
369
+ # explanation line above it.
370
+ features=(True, True, False, True),
371
+ ),
372
+ ProjectTemplate(
373
+ id="edge-ai-starter",
374
+ label="Edge AI starter",
375
+ description=(
376
+ "West-buildable BME280 cold-chain-monitor app wired to board.yaml via "
377
+ "the SDK loader."
378
+ ),
379
+ explanation=(
380
+ "Real Zephyr app vendored from the SDK's `edge-ai` scaffold: a BME280 "
381
+ "cold-chain integrity monitor (MKT / dewpoint / excursion time) plus a "
382
+ "TFLite-Micro anomaly score.",
383
+ 'Heterogeneous board.yaml: the app core (m33_sm/m55_hp) runs inference '
384
+ 'alongside a companion Cortex-A cluster (a55_cluster/a32_cluster, os: '
385
+ '"off").',
386
+ "Build with `west build -b <board>` after `export ALP_SDK_ROOT=<your "
387
+ "alp-sdk checkout>`.",
388
+ ),
389
+ ),
390
+ ProjectTemplate(
391
+ id="board-diagnostics",
392
+ label="Board diagnostics",
393
+ description=(
394
+ "West-buildable board self-test app wired to board.yaml via the SDK loader."
395
+ ),
396
+ explanation=(
397
+ "Real Zephyr app vendored from the SDK's `diagnostics` scaffold: reads "
398
+ "the SoM/SoC identity, the RUN operating-point profile, and scans the "
399
+ "on-module I2C management bus for a pass/fail bring-up report.",
400
+ "Portable <alp/*> APIs only -- no chip driver -- so the same source runs "
401
+ "on every E1M family; a check a backend can't service reports SKIP, not "
402
+ "FAIL.",
403
+ "Build with `west build -b <board>` after `export ALP_SDK_ROOT=<your "
404
+ "alp-sdk checkout>`.",
405
+ ),
406
+ ),
407
+ ProjectTemplate(
408
+ id="multicore-mailbox",
409
+ label="Multicore -- mailbox (AEN M55-HP <-> M55-HE)",
410
+ description=(
411
+ "Dual-Zephyr-core starter for the Alif Ensemble E8: two Cortex-M55 "
412
+ "cores, both real project cores."
413
+ ),
414
+ explanation=(
415
+ "Vendored from the SDK's `multicore-mailbox` scaffold. Both cores get "
416
+ "their own app -- `m55_hp: app: ./src` and `m55_he: app: ./peer` -- "
417
+ "which is the topology no --template/--cores combination could "
418
+ "scaffold before (tan-cli#864).",
419
+ "HP stages a payload in cache-coherent shared SRAM and signals HE "
420
+ "through the hardware mailbox over the portable <alp/mproc.h> "
421
+ "raw-shmem + mailbox + hwsem surface; HE echoes it back. Deliberately "
422
+ "NOT RPMsg -- no framing and no negotiated channel, just a raw pointer "
423
+ "view over a fixed carve-out plus a doorbell.",
424
+ "E1M-AEN801 only: the SDK catalog gates this template's "
425
+ "`supported.som_skus` to that one SKU and refuses to emit it for any "
426
+ "other, so tan refuses the same set rather than rendering it against "
427
+ "another tree.",
428
+ ),
429
+ ),
430
+ )
431
+
432
+ #: The module-scaffold templates, in registry order --
433
+ #: `MODULE_TEMPLATE_DEFINITIONS` (same Rust file).
434
+ MODULE_TEMPLATES: tuple[ModuleTemplate, ...] = (
435
+ ModuleTemplate(
436
+ id="sensor-driver",
437
+ label="Sensor driver module",
438
+ description="Adds a source/header pair for sensor acquisition logic.",
439
+ function_prefix="alp_sensor",
440
+ ),
441
+ ModuleTemplate(
442
+ id="connectivity-service",
443
+ label="Connectivity service module",
444
+ description="Adds module skeleton for network/session orchestration.",
445
+ function_prefix="alp_conn",
446
+ ),
447
+ ModuleTemplate(
448
+ id="inference-stage",
449
+ label="Inference stage module",
450
+ description="Adds module skeleton for model pre/post processing path.",
451
+ function_prefix="alp_infer",
452
+ ),
453
+ ModuleTemplate(
454
+ id="diagnostics-check",
455
+ label="Diagnostics check module",
456
+ description="Adds bring-up and runtime health-check module scaffold.",
457
+ function_prefix="alp_diag",
458
+ ),
459
+ )
460
+
461
+ #: Every generation target, in fixed catalog order. All TEN of them: tan-cli#165
462
+ #: was the catalogue carrying only four, so `tan explain --target zephyr-board`
463
+ #: answered "Unknown generation target" for a target `tan generate` accepts.
464
+ #: Kept in step with `generate_cmd._OUTPUT_RELATIVE_PATH` by
465
+ #: `tests/commands/test_explain_command.py`, which is the guard against those
466
+ #: two drifting apart again -- the exact failure #165 was.
467
+ GENERATION_TARGETS: tuple[GenerationTarget, ...] = (
468
+ GenerationTarget(
469
+ emit="zephyr-conf",
470
+ display_name="Zephyr config",
471
+ output_relative_path="build/generated/alp.conf",
472
+ preview_label="Zephyr config preview",
473
+ preview_language_id="properties",
474
+ ),
475
+ GenerationTarget(
476
+ emit="dts-overlay",
477
+ display_name="Devicetree overlay",
478
+ output_relative_path="build/generated/alp.overlay",
479
+ preview_label="Devicetree overlay preview",
480
+ preview_language_id="dts",
481
+ ),
482
+ GenerationTarget(
483
+ emit="cmake-args",
484
+ display_name="CMake args",
485
+ output_relative_path="build/generated/alp-cmake-args.txt",
486
+ preview_label="CMake args preview",
487
+ preview_language_id="plaintext",
488
+ ),
489
+ GenerationTarget(
490
+ emit="yocto-conf",
491
+ display_name="Yocto config",
492
+ output_relative_path="build/generated/alp-yocto.conf",
493
+ preview_label="Yocto config preview",
494
+ preview_language_id="properties",
495
+ ),
496
+ GenerationTarget(
497
+ emit="native-sim-overlay",
498
+ display_name="native_sim overlay",
499
+ # The one target outside build/generated/: Zephyr auto-discovers
500
+ # boards/<board>.overlay in the app's own SOURCE tree. Must stay
501
+ # verbatim equal to generate_cmd's path table.
502
+ output_relative_path="boards/native_sim_native_64.overlay",
503
+ preview_label="native_sim overlay preview",
504
+ preview_language_id="dts",
505
+ ),
506
+ GenerationTarget(
507
+ emit="carrier-netlist",
508
+ display_name="Carrier netlist",
509
+ output_relative_path="build/generated/carrier-netlist.json",
510
+ preview_label="Carrier netlist preview",
511
+ preview_language_id="json",
512
+ ),
513
+ GenerationTarget(
514
+ emit="west-libraries",
515
+ display_name="West libraries",
516
+ output_relative_path="build/generated/alp-west-libs.yml",
517
+ preview_label="West libraries preview",
518
+ preview_language_id="yaml",
519
+ ),
520
+ GenerationTarget(
521
+ emit="hw-info-h",
522
+ display_name="Hardware info header",
523
+ output_relative_path="build/generated/alp_hw_info_build.h",
524
+ preview_label="Hardware info header preview",
525
+ preview_language_id="c",
526
+ ),
527
+ GenerationTarget(
528
+ emit="os-topology",
529
+ display_name="OS topology",
530
+ output_relative_path="build/generated/os-topology.json",
531
+ preview_label="OS topology preview",
532
+ preview_language_id="json",
533
+ ),
534
+ GenerationTarget(
535
+ emit="zephyr-board",
536
+ display_name="Zephyr board tree",
537
+ # Documentary only (`is_directory`): the real name is resolved per-run
538
+ # by `generate_cmd.zephyr_board_dir_name`. Never joined onto a
539
+ # workspace root from here -- this command writes nothing at all.
540
+ output_relative_path="build/boards/alp_e1m_<sku-slug>_<core>/",
541
+ preview_label="Zephyr board tree preview",
542
+ preview_language_id="plaintext",
543
+ is_directory=True,
544
+ ),
545
+ )
546
+
547
+
548
+ class ExplainError(Exception):
549
+ """A failure with its issue code, message, exit code and human line already
550
+ decided. `selector_value` is the id the caller asked about, echoed back in
551
+ `data.selector.value` (empty for an ambiguous selector, which named two)."""
552
+
553
+ def __init__(
554
+ self,
555
+ code: str,
556
+ message: str,
557
+ text_line: str,
558
+ *,
559
+ exit_code: ExitCode = ExitCode.RUNTIME_FAILURE,
560
+ selector_value: str = "",
561
+ extra_data: dict | None = None,
562
+ extra_issues: list[Issue] | None = None,
563
+ ) -> None:
564
+ super().__init__(message)
565
+ self.code = code
566
+ self.message = message
567
+ self.text_line = text_line
568
+ self.exit_code = exit_code
569
+ self.selector_value = selector_value
570
+ #: Keys folded into the failure envelope's `data` -- only the `--code`
571
+ #: miss uses it, to carry `suggestions` next to the refusal instead of
572
+ #: burying the shortlist in an issue message a consumer must re-parse.
573
+ self.extra_data = extra_data or {}
574
+ #: tan-cli#950: PREPENDED ahead of this error's own `Issue` by
575
+ #: `_fail`, the same `[*resolution_issues, Issue(...)]` shape
576
+ #: `clean_cmd._run` / `bootstrap_cmd._refusal` / `new_som_cmd.new_som`
577
+ #: use. Only `bind_sdk`'s `explain.sdk-root-unresolved` raise
578
+ #: populates this today (`sdk.project-pin-unresolved` /
579
+ #: `sdk.global-default-foreign-project`, tan-cli#263 review /
580
+ #: tan-cli#464): it is the one `ExplainError` site that can compute a
581
+ #: broken-pin/foreign-default fact and then discard the checkout it
582
+ #: came from, so it is also the one site the shared `Envelope(...,
583
+ #: sdk=...)` advisory machinery (`_with_sdk_resolution_advisories`)
584
+ #: cannot reach -- that machinery fires only when an `SdkInfo` was
585
+ #: actually built, and `bind_sdk` raises before building one.
586
+ self.extra_issues = extra_issues or []
587
+
588
+
589
+ @dataclass
590
+ class _Result:
591
+ """A resolved (non-error) explanation."""
592
+
593
+ kind: str
594
+ value: str
595
+ summary: str
596
+ details: list[str] = field(default_factory=list)
597
+ #: `--code` only: the catalogue entry verbatim plus an (empty on a hit)
598
+ #: suggestion list, folded into `data`. Empty for every other selector, so
599
+ #: their envelopes keep the exact key set the golden pins.
600
+ extra_data: dict = field(default_factory=dict)
601
+
602
+
603
+ # ---------------------------------------------------------------------------
604
+ # Detail lines
605
+ # ---------------------------------------------------------------------------
606
+
607
+
608
+ def _format_library_names(names: list[str]) -> str:
609
+ """A comma-joined library list, or `(none)` when empty. `(none)` is the
610
+ SOURCED answer for `zephyr-app`/`sensor-starter`/`board-diagnostics` --
611
+ their vendored board.yaml has no `libraries:` block -- not a default."""
612
+ return ", ".join(names) if names else "(none)"
613
+
614
+
615
+ def _format_feature_flags(features: tuple[bool, bool, bool, bool]) -> str:
616
+ """`wifi=.. mqtt=.. ble=.. tls=..` with Rust's lowercase bool spelling.
617
+ `str(True)` would emit `True`, which no consumer of this line expects."""
618
+ wifi, mqtt, ble, tls = (str(f).lower() for f in features)
619
+ return f"wifi={wifi} mqtt={mqtt} ble={ble} tls={tls}"
620
+
621
+
622
+ def _som_support_data(template_id: str) -> dict[str, object]:
623
+ """`data.som` for one project template -- what `tan init --template
624
+ <template_id>` will ACCEPT or REFUSE for `--som`, READ (never retyped)
625
+ from `TEMPLATE_SUPPORTED_SKUS`/`UNSUPPORTED_SOM_FAMILY_PREFIXES`
626
+ (`tan.core.scaffold`) so this cannot drift from the refusal it describes
627
+ (tan-cli#866).
628
+
629
+ PR #985 review, major 1: named `initAcceptsSkus`/`initRefusesSkuPrefixes`
630
+ ON PURPOSE, not `supportedSkus`/`unsupportedSkuPrefixes` -- this is a
631
+ REFUSAL policy, not a capability statement, and the two provably differ:
632
+ `tan.core.scaffold._family_bucket` falls an unrecognised SKU prefix onto
633
+ the default (Alif) tree rather than refusing it, so for every template
634
+ here except `iot-starter`/`multicore-mailbox`, `initAcceptsSkus` is wider
635
+ than alp-sdk's own scaffold-catalog `supported.som_skus` (see the module
636
+ docstring above for the measured gap). A name that said "supported"
637
+ would claim capability this field cannot back for 4 of 7 templates.
638
+
639
+ `initAcceptsSkus` mirrors the `init.invalid-som` allowlist -- `None` when
640
+ this template carries none. `initRefusesSkuPrefixes` mirrors the
641
+ `init.som-unsupported` family exclusion -- always `[]` when
642
+ `initAcceptsSkus` is set, because an exact-SKU allowlist is strictly
643
+ narrower than (and already implies) that exclusion; repeating it would be
644
+ a second way to say the same thing, the exact class of duplication this
645
+ field exists to retire. A template with neither restriction
646
+ (`minimal-app`, tan's one vendor-neutral, non-family-gated template --
647
+ `scaffold.is_family_gated` is `False` for it alone) reports
648
+ `initAcceptsSkus: null, initRefusesSkuPrefixes: []`: `tan init` accepts
649
+ every SoM for it, unconditionally.
650
+ """
651
+ supported = TEMPLATE_SUPPORTED_SKUS.get(template_id)
652
+ if supported is not None:
653
+ return {"initAcceptsSkus": list(supported), "initRefusesSkuPrefixes": []}
654
+ if is_family_gated(template_id):
655
+ return {
656
+ "initAcceptsSkus": None,
657
+ "initRefusesSkuPrefixes": list(UNSUPPORTED_SOM_FAMILY_PREFIXES),
658
+ }
659
+ return {"initAcceptsSkus": None, "initRefusesSkuPrefixes": []}
660
+
661
+
662
+ def _family_excluded_note(template_id: str) -> str | None:
663
+ """One `details[]` line for a FAMILY-gated (but not exact-SKU-gated)
664
+ project template, DERIVED from `UNSUPPORTED_SOM_FAMILY_PREFIXES` -- `None`
665
+ when there is nothing to add.
666
+
667
+ PR #985 review, minor 5: before this, text mode said nothing at all about
668
+ the family exclusion for `zephyr-app`/`sensor-starter`/`edge-ai-starter`/
669
+ `board-diagnostics` even after `data.som.initRefusesSkuPrefixes` started
670
+ carrying it in JSON -- a human running `tan explain --template zephyr-app`
671
+ with no `--format json` only discovered the restriction as
672
+ `init.som-unsupported`, at `tan init` time. `None` for `minimal-app` (not
673
+ family-gated at all) and for the two exact-SKU-gated templates
674
+ (`iot-starter`/`multicore-mailbox`): `_only_note` already reports their
675
+ narrower restriction from the description line, so a second sentence here
676
+ would repeat it rather than add information."""
677
+ if template_id in TEMPLATE_SUPPORTED_SKUS or not is_family_gated(template_id):
678
+ return None
679
+ if not UNSUPPORTED_SOM_FAMILY_PREFIXES:
680
+ return None
681
+ prefixes = ", ".join(UNSUPPORTED_SOM_FAMILY_PREFIXES)
682
+ return f"Refuses --som for these SoM families: {prefixes}."
683
+
684
+
685
+ def _project_template_details(pt: ProjectTemplate) -> list[str]:
686
+ """Description, per-template explanation, the family-exclusion note (when
687
+ one applies), default libraries, default features. Raises
688
+ `TemplateDataError` when the vendored board.yaml behind the libraries
689
+ line will not read."""
690
+ names = vendored_library_names_for(pt.id)
691
+ if names is None:
692
+ names = list(pt.libs) # `minimal-app` only: no vendored tree to read.
693
+ details = [pt.description, *pt.explanation]
694
+ family_note = _family_excluded_note(pt.id)
695
+ if family_note is not None:
696
+ details.append(family_note)
697
+ details.append(f"Default libraries: {_format_library_names(names)}")
698
+ details.append(f"Default features: {_format_feature_flags(pt.features)}")
699
+ return details
700
+
701
+
702
+ def _module_template_details(mt: ModuleTemplate) -> list[str]:
703
+ return [
704
+ mt.description,
705
+ f"Function prefix: {mt.function_prefix}",
706
+ "Use this template with tan scaffold to generate a module source/header "
707
+ "baseline.",
708
+ ]
709
+
710
+
711
+ def _generation_target_details(target: GenerationTarget) -> list[str]:
712
+ """tan-cli#165: `zephyr-board`'s path is a documentary template, so the
713
+ "(writes a directory of files, not one path)" suffix says so instead of
714
+ implying a real single file the way the other nine targets' paths are."""
715
+ note = " (writes a directory of files, not one path)" if target.is_directory else ""
716
+ return [
717
+ f"Display name: {target.display_name}",
718
+ f"Output path: {target.output_relative_path}{note}",
719
+ f"Preview label: {target.preview_label}",
720
+ f"Preview language: {target.preview_language_id}",
721
+ ]
722
+
723
+
724
+ def _print_detail_lines(details: list[str], width: int | None) -> None:
725
+ """`- <detail>` per entry, to stderr -- hard-wrapped past `width` when it
726
+ is not `None` (a real terminal; see `tan.env.wrap_width`), unwrapped
727
+ verbatim off one (piped/redirected, or non-interactive) -- the property
728
+ `tan explain | grep` and every existing golden depend on. Every detail
729
+ wraps the same way; see `tan.core.text_layout.wrap_lines` for why there
730
+ is no longer a "record-shaped" exemption here.
731
+ """
732
+ for line in wrap_lines([f"- {detail}" for detail in details], width):
733
+ print(line, file=sys.stderr)
734
+
735
+
736
+ # ---------------------------------------------------------------------------
737
+ # Resolution
738
+ # ---------------------------------------------------------------------------
739
+
740
+
741
+ def _clean(raw: str | None) -> str | None:
742
+ """A selector as Rust reads it: trimmed, and blank counts as ABSENT --
743
+ `--template " "` prints the overview, it is not an unknown template."""
744
+ if raw is None:
745
+ return None
746
+ trimmed = raw.strip()
747
+ return trimmed or None
748
+
749
+
750
+ def _overview() -> _Result:
751
+ return _Result(
752
+ kind="overview",
753
+ value="all",
754
+ summary="tan explain topics",
755
+ details=[
756
+ "Use --template to explain a project template (init) or module template "
757
+ "(scaffold).",
758
+ "Use --target to explain a generation output target.",
759
+ "Project templates: " + ", ".join(t.id for t in PROJECT_TEMPLATES),
760
+ "Module templates: " + ", ".join(t.id for t in MODULE_TEMPLATES),
761
+ "Generation targets: " + ", ".join(t.emit for t in GENERATION_TARGETS),
762
+ ],
763
+ )
764
+
765
+
766
+ def resolve(template: str | None, target: str | None) -> _Result:
767
+ """The explanation for this selector pair. Raises `ExplainError` for both
768
+ selectors at once or an unknown id, and `TemplateDataError` when a vendored
769
+ board.yaml will not read.
770
+
771
+ A project template shadows a module template of the same id, matching the
772
+ Rust's search order -- the two id sets are disjoint today, and this keeps
773
+ the tie-break explicit rather than accidental if they ever collide.
774
+ """
775
+ template = _clean(template)
776
+ target = _clean(target)
777
+
778
+ if template is not None and target is not None:
779
+ raise ExplainError(
780
+ "explain.ambiguous-selector",
781
+ "Use either --template or --target for explain, not both.",
782
+ "explain: use either --template or --target, but not both in the same "
783
+ "command.",
784
+ )
785
+
786
+ if template is not None:
787
+ for pt in PROJECT_TEMPLATES:
788
+ if pt.id == template:
789
+ return _Result(
790
+ kind="project-template",
791
+ value=pt.id,
792
+ summary=f"{pt.label} ({pt.id})",
793
+ details=_project_template_details(pt),
794
+ # tan-cli#866: structured, so a consumer filters a picker
795
+ # without parsing `details[]` prose. Project templates
796
+ # only -- module templates and generation targets carry
797
+ # no SoM concept.
798
+ extra_data={"som": _som_support_data(pt.id)},
799
+ )
800
+ for mt in MODULE_TEMPLATES:
801
+ if mt.id == template:
802
+ return _Result(
803
+ kind="module-template",
804
+ value=mt.id,
805
+ summary=f"{mt.label} ({mt.id})",
806
+ details=_module_template_details(mt),
807
+ )
808
+ raise ExplainError(
809
+ "explain.template-unknown",
810
+ f"Unknown template '{template}'.",
811
+ f"explain: unknown template '{template}'. Run tan explain without "
812
+ f"selectors to list available topics.{_code_hint(template)}",
813
+ selector_value=template,
814
+ )
815
+
816
+ if target is not None:
817
+ for gt in GENERATION_TARGETS:
818
+ if gt.emit == target:
819
+ return _Result(
820
+ kind="generation-target",
821
+ value=gt.emit,
822
+ summary=f"{gt.display_name} ({gt.emit})",
823
+ details=_generation_target_details(gt),
824
+ )
825
+ raise ExplainError(
826
+ "explain.target-unknown",
827
+ f"Unknown generation target '{target}'.",
828
+ f"explain: unknown generation target '{target}'. Run tan explain "
829
+ f"without selectors to list available targets.",
830
+ selector_value=target,
831
+ )
832
+
833
+ return _overview()
834
+
835
+
836
+ def bind_sdk(sdk_root_arg: str | None, project: str | None, code: str) -> tuple[Path, SdkInfo]:
837
+ """The checkout `--code` reads, via the NARROW ladder (`--sdk-root` >
838
+ `.alp/sdk-path` > `~/.alp/sdk-default` > discovery), walked from the
839
+ `--project`-resolved workspace root -- `os.path.join(cwd, project)`, the
840
+ same join `build_cmd.build`/`run_cmd.run`/`inspect_cmd.inspect` use, so an
841
+ absolute `--project` replaces the cwd outright rather than nesting under it.
842
+
843
+ Narrow because `explain` is not one of the three commands the oracle routes
844
+ wide (`init`/`generate`/`examples`), so following the
845
+ thirteen-command majority makes `tan explain --code` answer out of the same
846
+ checkout `tan validate` used in the same directory -- the only reason to
847
+ look a code up while a build is failing. `--project` IS read here (unlike
848
+ every other selector on this command, see `explain`'s docstring): it is the
849
+ one input this whole ladder exists to resolve against, and the twelve other
850
+ `resolve_sdk_root_ladder` call sites all pass a `--project`-derived root,
851
+ not the bare CWD -- accepting the flag and then resolving from the CWD
852
+ anyway is the accepted-but-ignored input class this command refuses
853
+ everywhere else (`explain`'s `--sdk-root` rationale).
854
+
855
+ Raises `ExplainError` when no tier resolves anything, INCLUDING when
856
+ `--sdk-root` (the terminal tier) points at a directory that resolves but
857
+ carries no `SDK_MARKER` -- `resolve_sdk_root_ladder` itself does not check
858
+ that for its terminal tier (I-31: a typo'd flag must not fall through to a
859
+ lower tier), so an unmarked `--sdk-root` would otherwise reach
860
+ `resolve_code` and misreport as `explain.catalog-unreadable` ("run the
861
+ generator" in a checkout that was never one) instead of naming the real
862
+ problem. Either way this is a refusal naming what it could not find, never
863
+ an empty answer. Both imports are LOCAL so the three SDK-free paths keep
864
+ paying nothing for a mode they never enter.
865
+
866
+ tan-cli#950: the unresolved-SDK raise carries `resolution.broken_project_pin`
867
+ / `.foreign_global_default_for` on `ExplainError.extra_issues` (the
868
+ `clean_cmd._run` / `bootstrap_cmd._refusal` / `new_som_cmd.new_som` shape --
869
+ the eighth instance of the tan-cli#900 class). This is the ONE branch that
870
+ needs it computed explicitly: the SUCCESS return below hands its `SdkInfo`
871
+ to `_emit`'s `Envelope(..., sdk=...)`, whose own
872
+ `_with_sdk_resolution_advisories` already discloses the same pair
873
+ generically from `SdkInfo.broken_project_pin` -- but `bind_sdk` raising
874
+ here means no `SdkInfo` is ever built, so that shared machinery never
875
+ runs and the fact would otherwise be discarded with the rest of
876
+ `resolution`.
877
+ """
878
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
879
+ from tan.core.sdk_discovery import (
880
+ global_default_foreign_project_issue,
881
+ project_pin_issue,
882
+ resolve_sdk_root_ladder,
883
+ with_sdk_search,
884
+ )
885
+ from tan.core.shapes import SDK_MARKER
886
+
887
+ cwd = Path.cwd()
888
+ workspace_root = cwd if project is None else Path(os.path.join(str(cwd), project))
889
+ resolution = resolve_sdk_root_ladder(sdk_root_arg, workspace_root)
890
+ if resolution.path is None or not resolution.path.joinpath(*SDK_MARKER).exists():
891
+ pin_issue = project_pin_issue(resolution.broken_project_pin, resolution.tier)
892
+ foreign_issue = global_default_foreign_project_issue(
893
+ resolution.foreign_global_default_for
894
+ )
895
+ raise ExplainError(
896
+ "explain.sdk-root-unresolved",
897
+ with_sdk_search(
898
+ f"alp-sdk root is unresolved, so no diagnostic catalogue could be "
899
+ f"read -- {NO_SDK_NEXT_STEPS}.",
900
+ workspace_root,
901
+ sdk_root_arg,
902
+ ),
903
+ with_sdk_search(
904
+ f"explain: alp-sdk root is unresolved, so no diagnostic catalogue "
905
+ f"could be read -- {NO_SDK_NEXT_STEPS}.",
906
+ workspace_root,
907
+ sdk_root_arg,
908
+ ),
909
+ selector_value=code.strip(),
910
+ extra_issues=[i for i in (pin_issue, foreign_issue) if i is not None],
911
+ )
912
+ return resolution.path, SdkInfo.from_resolution(str(resolution.path), resolution)
913
+
914
+
915
+ def _unknown_code_line(code: str, suggestions: list[str]) -> str:
916
+ """The difflib shortlist when there is one, otherwise a pointer at the
917
+ catalogue that holds every real code.
918
+
919
+ Only the WITH-SUGGESTIONS branch is `explain.py`'s own miss sentence,
920
+ verbatim. The no-near-miss branch is tan's OWN wording, not a port: measured
921
+ against alp-sdk `origin/dev`, `explain.py` there prints ``unknown code
922
+ 'ZZZ' -- run `alp explain` against a code from metadata/error-
923
+ catalog.json`` (its own binary name, its own subcommand spelling), which
924
+ this port cannot repeat verbatim -- there is no `alp explain` in a
925
+ `tan`-only install. See the module DIVERGENCE list above.
926
+ """
927
+ if suggestions:
928
+ return f"explain: unknown code '{code}'; did you mean: " + ", ".join(suggestions) + "?"
929
+ return (
930
+ f"explain: unknown code '{code}' -- pass a code from the SDK's "
931
+ f"metadata/error-catalog.json."
932
+ )
933
+
934
+
935
+ def resolve_code(code: str, sdk_root: Path) -> _Result:
936
+ """The explanation for one `ALP_ERR_*` / `ALP-Bxxx` code out of `sdk_root`.
937
+
938
+ Raises `ExplainError` for an unreadable catalogue and for an unknown code.
939
+ The miss carries its shortlist in `data.suggestions` as well as in the text
940
+ line, so a consumer gets the recovery options structured instead of having
941
+ to re-parse a sentence.
942
+ """
943
+ try:
944
+ codes = error_catalog.load_codes(sdk_root)
945
+ except error_catalog.CatalogUnreadable as err:
946
+ raise ExplainError(
947
+ "explain.catalog-unreadable",
948
+ f"{err.detail} ({err.path}).",
949
+ f"explain: {err.detail} ({err.path}).",
950
+ selector_value=code.strip(),
951
+ ) from None
952
+ key, suggestions = error_catalog.lookup(codes, code)
953
+ if key is None:
954
+ raise ExplainError(
955
+ "explain.code-unknown",
956
+ f"Unknown diagnostic code '{code}'.",
957
+ _unknown_code_line(code, suggestions),
958
+ selector_value=code.strip(),
959
+ extra_data={"diagnostic": None, "suggestions": suggestions},
960
+ )
961
+ entry = codes[key]
962
+ return _Result(
963
+ kind="diagnostic-code",
964
+ value=key,
965
+ summary=error_catalog.summary_line(entry),
966
+ details=error_catalog.detail_lines(entry),
967
+ extra_data={"diagnostic": entry, "suggestions": []},
968
+ )
969
+
970
+
971
+ # ---------------------------------------------------------------------------
972
+ # Envelope assembly
973
+ # ---------------------------------------------------------------------------
974
+
975
+
976
+ def _available() -> dict:
977
+ """The full catalogue of explainable ids. Emitted on EVERY path, success and
978
+ failure alike, so a caller that guessed an id wrong learns the valid ones
979
+ from the same envelope."""
980
+ return {
981
+ "projectTemplates": [t.id for t in PROJECT_TEMPLATES],
982
+ "moduleTemplates": [t.id for t in MODULE_TEMPLATES],
983
+ "generationTargets": [t.emit for t in GENERATION_TARGETS],
984
+ }
985
+
986
+
987
+ def _data(
988
+ *, kind: str, value: str, summary: str, details: list[str], extra: dict | None = None
989
+ ) -> dict:
990
+ """`extra` is MERGED LAST and is empty on every path but `--code`: the
991
+ `explain-overview` golden pins this key set exactly, so a key that appears
992
+ unconditionally is a wire change for three selectors that gained nothing."""
993
+ return {
994
+ "schemaVersion": DATA_SCHEMA_VERSION,
995
+ "selector": {"kind": kind, "value": value},
996
+ "summary": summary,
997
+ "details": details,
998
+ "available": _available(),
999
+ **(extra or {}),
1000
+ }
1001
+
1002
+
1003
+ def _print_sdk_resolution_warnings(sdk: SdkInfo | None) -> None:
1004
+ """tan-cli#959: `explain: warning: <message>` lines to stderr for every
1005
+ fact `sdk_resolution_issues` finds in `sdk`'s resolution -- the text-mode
1006
+ disclosure of the same `sdk.project-pin-unresolved` /
1007
+ `sdk.global-default-foreign-project` pair `Envelope`'s
1008
+ `_with_sdk_resolution_advisories` already appends to `issues[]` for free
1009
+ under `--format json` (it fires there because `_emit`'s json branch is
1010
+ the only branch that ever constructs an `Envelope`). This is the text
1011
+ branch's own copy of that disclosure, called both from the success path
1012
+ (`explain`, ahead of the summary line) and from `_fail` (ahead of
1013
+ `err.text_line`) -- the two SDK-bound refusal sites, `resolve_code`'s
1014
+ `explain.catalog-unreadable` and `explain.code-unknown`, each bind an
1015
+ `SdkInfo` via `bind_sdk` before raising, so `sdk` is populated there the
1016
+ same way it is on success.
1017
+
1018
+ `sdk` is `None` on every path that never resolved a checkout --
1019
+ `--template`/`--target` (`resolve`'s three raises), the two selector-
1020
+ clash refusals (`explain.positional-template-conflict` fires before
1021
+ `--code` is even cleaned; `explain.ambiguous-selector` fires only once
1022
+ `--code` IS set, but both `_fail` before `bind_sdk` is ever called, so
1023
+ neither attempts checkout resolution), and `bind_sdk`'s own
1024
+ `explain.sdk-root-unresolved` raise (it raises BEFORE building an
1025
+ `SdkInfo`, which is why tan-cli#950 had to carry that one pair through
1026
+ `ExplainError.extra_issues` instead -- the two mechanisms are disjoint by
1027
+ construction, never double-printing the same fact).
1028
+ """
1029
+ if sdk is None:
1030
+ return
1031
+ from tan.core.sdk_discovery import sdk_resolution_issues
1032
+
1033
+ for issue in sdk_resolution_issues(
1034
+ sdk.broken_project_pin, sdk.source_tier, sdk.foreign_global_default_for
1035
+ ):
1036
+ for line in wrap_lines([f"explain: warning: {issue.message}"], wrap_width()):
1037
+ print(line, file=sys.stderr)
1038
+
1039
+
1040
+ def _emit(
1041
+ json_mode: bool,
1042
+ data: dict,
1043
+ issues: list[Issue],
1044
+ exit_code: ExitCode,
1045
+ sdk: SdkInfo | None = None,
1046
+ ) -> None:
1047
+ """Write the one envelope (JSON) and exit. `project` is always null/null --
1048
+ explain reads no board.yaml on ANY path, `--code` included: a diagnostic
1049
+ code is a property of the SDK, not of a project. `sdk` is populated only
1050
+ when `--code` resolved a checkout, which is also the only path that can
1051
+ carry the shared `sdk.*` resolution advisories `Envelope` appends."""
1052
+ if json_mode:
1053
+ emit(
1054
+ Envelope(
1055
+ "explain",
1056
+ Project(root=None, board_yaml=None),
1057
+ data,
1058
+ issues,
1059
+ exit_code,
1060
+ sdk=sdk,
1061
+ )
1062
+ )
1063
+ raise typer.Exit(int(exit_code))
1064
+
1065
+
1066
+ def explain(
1067
+ template_arg: str = typer.Argument(
1068
+ None,
1069
+ metavar="[TEMPLATE]",
1070
+ help="Project or module template id -- shorthand for --template.",
1071
+ ),
1072
+ template: str = typer.Option(
1073
+ None,
1074
+ "--template",
1075
+ metavar="ID",
1076
+ help="Project template (init) or module template (scaffold) to explain.",
1077
+ ),
1078
+ target: str = typer.Option(
1079
+ None,
1080
+ "--target",
1081
+ metavar="EMIT",
1082
+ help="Generation output target to explain (e.g. zephyr-conf, zephyr-board).",
1083
+ ),
1084
+ code: str = typer.Option(
1085
+ None,
1086
+ "--code",
1087
+ metavar="CODE",
1088
+ help="alp-sdk diagnostic or error code to explain (e.g. ALP-B003, "
1089
+ "ALP_ERR_NO_BACKEND). Reads the bound SDK checkout.",
1090
+ ),
1091
+ project: str = typer.Option( # read by --code ONLY; see below
1092
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1093
+ ),
1094
+ sdk_root: str = typer.Option( # read by --code ONLY; see below
1095
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1096
+ ),
1097
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1098
+ ) -> None:
1099
+ """Explain a project/module template or a generation target.
1100
+
1101
+ `--project` is declared on EVERY path but consumed by `--code` ALONE
1102
+ (`bind_sdk`): `--template`/`--target` are project-agnostic (they read no
1103
+ board.yaml, and the oracle's `project` stays `null`/`null` regardless of
1104
+ either flag's value on those two paths), but clap makes both `global =
1105
+ true` in Rust, so `tan --sdk-root X explain` / `tan --project X explain`
1106
+ must not be parse errors -- verified against the oracle. The reported
1107
+ envelope `project` key STILL stays `null`/`null` even on `--code`
1108
+ (`_emit`'s own docstring): `--project` there only picks WHICH checkout the
1109
+ SDK ladder resolves, the same role it plays for `tan validate`/`tan
1110
+ build`, it is not itself echoed as a project root. Accepting the flag and
1111
+ then resolving from the bare CWD regardless of its value would be the
1112
+ accepted-but-ignored input class this command refuses everywhere else --
1113
+ which is exactly what an earlier version of `bind_sdk` did (tan-cli#627
1114
+ review). `alp-sdk-vscode/src/ideHub/newProjectFlowPanel.ts:188` invokes
1115
+ exactly the `--sdk-root` shape. `--sdk-root` IS consumed, but only by
1116
+ `--code` (`bind_sdk`): it is the terminal tier of the SDK ladder (I-31),
1117
+ and the alternative -- accepting the flag that names the checkout the
1118
+ catalogue lives in and then discovering a different one -- is the
1119
+ accepted-but-ignored input class this command refuses elsewhere.
1120
+
1121
+ `[TEMPLATE]` (the positional) is a PORT-ONLY convenience, not oracle
1122
+ behavior: the real `crates/tan-cli` `ExplainArgs` has no positional field,
1123
+ only `#[arg(long)] template`, so `tan explain minimal-app` errors there too
1124
+ ("Got unexpected extra argument(s)") even though every other `tan`
1125
+ subcommand that names a single id (`inspect`, `generate --target`, ...)
1126
+ reads naturally with one. Accepted here ONLY when `--template` is not
1127
+ already given; giving BOTH is a coded error (`explain.positional-template-
1128
+ conflict`), not a silent `--template`-wins -- the accepted-but-ignored
1129
+ input class this port keeps re-introducing. Folded into `template` before
1130
+ `resolve()` so the rest of this function is unaware of it.
1131
+ """
1132
+ json_mode = output_format == "json"
1133
+
1134
+ if template_arg is not None and template is not None:
1135
+ _fail(
1136
+ json_mode,
1137
+ ExplainError(
1138
+ "explain.positional-template-conflict",
1139
+ f"Use either the positional template id ('{template_arg}') or "
1140
+ f"--template ('{template}'), not both.",
1141
+ f"explain: use either the positional template id ('{template_arg}') "
1142
+ f"or --template ('{template}'), not both in the same command.",
1143
+ ),
1144
+ )
1145
+ return
1146
+
1147
+ # Captured BEFORE the fold below overwrites `template`: whether it was the
1148
+ # positional that supplied it (`--template` itself was absent), needed by
1149
+ # the `--code` clash message just below so it names what the caller
1150
+ # actually typed, the same way `explain.positional-template-conflict`
1151
+ # above already does.
1152
+ template_via_positional = template is None and template_arg is not None
1153
+
1154
+ if template is None:
1155
+ template = template_arg
1156
+
1157
+ code = _clean(code)
1158
+ if code is not None and (_clean(template) is not None or _clean(target) is not None):
1159
+ # FIRST, ahead of `resolve()`'s own template-vs-target check, so
1160
+ # `--template X --target Y --code Z` reports THIS message: it is the
1161
+ # accurate one for three selectors, where the pair's ("use either
1162
+ # --template or --target") names only two of them. The pair keeps its
1163
+ # own golden-pinned message on every input that reaches it, which is
1164
+ # every input with no `--code`. Same issue code for both -- one name
1165
+ # for "you named more than one thing to explain".
1166
+ #
1167
+ # `template_selector_name` names the POSITIONAL when that is what set
1168
+ # `template` (`tan explain minimal-app --code ALP-B003`): the fixed
1169
+ # wording named `--template` unconditionally there, even though the
1170
+ # caller never typed that flag -- the same accepted-but-ignored-input
1171
+ # confusion `explain.positional-template-conflict` exists to avoid.
1172
+ # `--target` stays fixed either way; it has no positional form.
1173
+ template_selector_name = (
1174
+ "the positional template id" if template_via_positional else "--template"
1175
+ )
1176
+ _fail(
1177
+ json_mode,
1178
+ ExplainError(
1179
+ "explain.ambiguous-selector",
1180
+ f"Use --code on its own; it cannot be combined with "
1181
+ f"{template_selector_name} or --target.",
1182
+ f"explain: use --code on its own, not combined with "
1183
+ f"{template_selector_name} or --target.",
1184
+ ),
1185
+ )
1186
+ return
1187
+
1188
+ sdk: SdkInfo | None = None
1189
+ try:
1190
+ if code is not None:
1191
+ # Two statements, not one: `bind_sdk` raising leaves `sdk` None
1192
+ # (the tuple never binds), which is exactly what the failure
1193
+ # envelope should report -- nothing was resolved. A `resolve_code`
1194
+ # failure below DOES carry the checkout it read, so the reader can
1195
+ # see WHICH catalogue answered "unknown".
1196
+ sdk_root_path, sdk = bind_sdk(sdk_root, project, code)
1197
+ result = resolve_code(code, sdk_root_path)
1198
+ else:
1199
+ result = resolve(template, target)
1200
+ except ExplainError as err:
1201
+ _fail(json_mode, err, sdk)
1202
+ return
1203
+ except TemplateDataError as err:
1204
+ # A broken tan installation (the vendored tree did not ship), not a
1205
+ # project problem -- so INTERNAL_FAILURE, matching `init`'s
1206
+ # `init.template-unreadable`. The Rust `expect()`s the same read and
1207
+ # panics, which under `--format json` leaves stdout EMPTY and the
1208
+ # extension rendering nothing; a coded envelope is the port's contract.
1209
+ _fail(
1210
+ json_mode,
1211
+ ExplainError(
1212
+ "explain.template-unreadable",
1213
+ str(err),
1214
+ f"explain: {err}",
1215
+ exit_code=ExitCode.INTERNAL_FAILURE,
1216
+ selector_value=(template or "").strip(),
1217
+ ),
1218
+ )
1219
+ return
1220
+ except Exception as err: # noqa: BLE001 -- the backstop; see the module docstring
1221
+ # `typer.Exit` cannot reach here: it is raised only from `_emit`, which
1222
+ # runs outside this try.
1223
+ _fail(
1224
+ json_mode,
1225
+ ExplainError(
1226
+ "explain.internal-failure",
1227
+ f"explain failed unexpectedly: {err.__class__.__name__}: {err}",
1228
+ f"explain: failed unexpectedly: {err.__class__.__name__}: {err}",
1229
+ exit_code=ExitCode.INTERNAL_FAILURE,
1230
+ selector_value=(template or target or code or "").strip(),
1231
+ ),
1232
+ sdk,
1233
+ )
1234
+ return
1235
+
1236
+ if not json_mode:
1237
+ # stderr, in both formats: stdout is the envelope channel and nothing
1238
+ # else. Matches Rust's `emit()`, which `eprintln!`s every text line.
1239
+ # tan-cli#959: the SDK-resolution advisories (`sdk.project-pin-
1240
+ # unresolved` / `sdk.global-default-foreign-project`) print FIRST,
1241
+ # matching `_fail`'s own order and `bootstrap_cmd._refusal`'s
1242
+ # `warning_lines + outcome.text` -- a caveat about which checkout
1243
+ # answered belongs ahead of the answer itself, not after it.
1244
+ # The summary header is a short, bounded "<label> (<id>)" -- never
1245
+ # observed over any real width, so it is never run through
1246
+ # `wrap_block`; `_print_detail_lines` is where the actual wrap
1247
+ # decision lives.
1248
+ _print_sdk_resolution_warnings(sdk)
1249
+ print(f"explain: {result.summary}", file=sys.stderr)
1250
+ _print_detail_lines(result.details, wrap_width())
1251
+ _emit(
1252
+ json_mode,
1253
+ _data(
1254
+ kind=result.kind,
1255
+ value=result.value,
1256
+ summary=result.summary,
1257
+ details=result.details,
1258
+ extra=result.extra_data,
1259
+ ),
1260
+ [],
1261
+ ExitCode.SUCCESS,
1262
+ sdk,
1263
+ )
1264
+
1265
+
1266
+ def _fail(json_mode: bool, err: ExplainError, sdk: SdkInfo | None = None) -> None:
1267
+ """The error envelope. Note the asymmetry with the success path, which is
1268
+ contract: `selector.kind` reverts to `overview` and summary/details go
1269
+ EMPTY even though the caller named a selector, because nothing was
1270
+ explained. `data.available` stays populated."""
1271
+ if not json_mode:
1272
+ # tan-cli#950: `err.extra_issues` (today only `bind_sdk`'s broken-pin
1273
+ # / foreign-global-default pair) printed AHEAD of the refusal line --
1274
+ # `bootstrap_cmd._refusal`'s `warning_lines + outcome.text` order --
1275
+ # since this text branch is the only path those facts reach; the JSON
1276
+ # branch below carries them in `issues[]` instead. Without this,
1277
+ # `--format json` would disclose the pin (tan-cli#677's asymmetry).
1278
+ for issue in err.extra_issues:
1279
+ for line in wrap_lines([f"explain: warning: {issue.message}"], wrap_width()):
1280
+ print(line, file=sys.stderr)
1281
+ # tan-cli#959: the other half of the same disclosure -- `sdk` is
1282
+ # populated (not `err.extra_issues`) on the two SDK-BOUND refusals,
1283
+ # `explain.catalog-unreadable` / `explain.code-unknown`, since both
1284
+ # raise from `resolve_code` AFTER `bind_sdk` already built an
1285
+ # `SdkInfo`. Disjoint from the loop above by construction (see
1286
+ # `_print_sdk_resolution_warnings`'s docstring), so this never
1287
+ # double-prints.
1288
+ _print_sdk_resolution_warnings(sdk)
1289
+ # A refusal sentence, not a record -- `--template`/`--target` echo
1290
+ # the CALLER's own (unbounded-length) input back into it (e.g.
1291
+ # "unknown template '<whatever was typed>'"), so this is the one
1292
+ # `explain` text line genuinely able to run long on a hostile or
1293
+ # just-long argument, not only in the two catalogue commands this
1294
+ # task is scoped to.
1295
+ for line in wrap_lines([err.text_line], wrap_width()):
1296
+ print(line, file=sys.stderr)
1297
+ _emit(
1298
+ json_mode,
1299
+ _data(
1300
+ kind="overview",
1301
+ value=err.selector_value,
1302
+ summary="",
1303
+ details=[],
1304
+ extra=err.extra_data,
1305
+ ),
1306
+ [*err.extra_issues, Issue(err.code, "error", err.message)],
1307
+ err.exit_code,
1308
+ sdk,
1309
+ )
1310
+
1311
+
1312
+ # tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was
1313
+ # still missing (`--all`/`--board-yaml`/`--ci`/`--no-color`/
1314
+ # `--non-interactive`/`--quiet`/`--verbose`) on top of `--target`, already
1315
+ # declared and read above; see `tan.core.global_flags`. `--project`/
1316
+ # `--sdk-root` are ALSO declared already (read by `--code` alone -- see
1317
+ # `explain`'s own docstring); the decorator leaves both untouched the same
1318
+ # way it leaves `--target` untouched.
1319
+ explain = accept_global_flags(explain)