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,568 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan pinmux` -- the E1M pinmux capability table (E1M pad -> silicon
3
+ function) for a SoM family (tan-cli#257).
4
+
5
+ Mirrors `crates/tan-cli/src/commands/pinmux.rs` plus the `tan-core` helpers it
6
+ composes (`pinmux::{parse_pinmux_table_checked, pinmux_family_for_sku}`).
7
+ Resolves a `metadata/pinmux/<family>.yaml` family stem from an explicit
8
+ `--family` or a `--sku` prefix, reads that table out of the resolved SDK root,
9
+ and echoes it in the envelope -- the single source the extension/LSP consume
10
+ instead of parsing `metadata/pinmux/<family>.yaml` themselves.
11
+
12
+ **Fail-soft, deliberately, with two exceptions** -- this paragraph's, and the
13
+ refused `--family` tan-cli#359 added below. An unresolved SDK root, an
14
+ unknown SKU, no `--sku`/`--family` at all, or a family with no generated table
15
+ on disk are each a `warning`-severity issue at exit 0 -- `pinmux` answers "I
16
+ don't know" the same way for all of them, never a hard failure. A table that
17
+ DOES exist on disk but fails to parse (schema-version skew) or parses to ZERO
18
+ pads (`pinmux-capability-v1.schema.json` requires `minItems: 1`, so an empty
19
+ table is never a legitimate v1 document -- and, measured against the real
20
+ `metadata/pinmux/v2n.yaml` in this checkout, an all-`"TBD"` family genuinely
21
+ reaches this today) is NOT fail-soft: `error` severity,
22
+ [`tan.exit_codes.ExitCode.VALIDATION_FAILURE`].
23
+
24
+ **No SDK-resolution warning for a broken project pin.** Unlike `presets`/
25
+ `sdk current`, this command never emits `sdk.project-pin-unresolved` --
26
+ measured against the oracle with a `.alp/sdk-path` pointing at a nonexistent
27
+ checkout: `pinmux` silently falls through to `pinmux.sdk-root-unresolved`
28
+ exactly as it would with no pointer at all. `resolve_project_paths`/
29
+ `resolve_sdk` (from `presets_cmd`, reused here rather than re-derived) already
30
+ carry the pin-rejection detail; this module simply never reads it.
31
+
32
+ **Row-level fail-soft mirrors `parse_pinmux_table_checked`, not the
33
+ un-checked `parse_pinmux_table`**: a pad row missing `e1m_pad`/`e1m_function`
34
+ is DROPPED (never an error), and a row's `e1m_pad == "TBD"` sentinel is
35
+ dropped too (the source TSV carries no E1M edge pad for that silicon pad --
36
+ `metadata/pinmux/v2n.yaml`'s ENTIRE table is TBD-only at the time of writing,
37
+ which is exactly what makes `pinmux.table-empty` a live path, not a
38
+ hypothetical one).
39
+
40
+ **`--family` is validated, and the resolved table path re-checked -- a
41
+ DELIBERATE divergence from the oracle (tan-cli#359).** The oracle builds
42
+ `sdk_root.join("metadata").join("pinmux").join(format!("{fam}.yaml"))` with no
43
+ check on `fam` at all, and `Path::join`/`pathlib` both DISCARD the accumulated
44
+ prefix when the joined component is absolute -- so `--family <other-sdk>/
45
+ metadata/pinmux/aen` read a table out of a completely different checkout while
46
+ the envelope still reported `sdkRoot` as the one it never touched (`..`
47
+ components walked out the same way). [`_is_safe_family_stem`] and the
48
+ [`resolve_confined`] re-check below close that; see `_resolve`'s own comment for
49
+ why BOTH are needed rather than either alone.
50
+
51
+ **A pad field is a `String` in the oracle, not a strict `serde_yaml`
52
+ struct-typed deserialize -- refuted by running it.** Every `PinmuxPad` field
53
+ is `String`, and (measured against the oracle) `String` fields coerce ANY
54
+ scalar to its own YAML-spelled text rather than rejecting a wrong-looking
55
+ one: `owner: 7` reads back `"7"`, `silicon_pad: true` reads back `"true"`,
56
+ both at exit 0 with no issue -- an earlier version of this module treated any
57
+ non-`str` scalar there as a hard parse failure, which was simply wrong, not a
58
+ documented divergence. Only a genuine compound value (`owner: [a, b]`,
59
+ `e1m_pad: {a: b}`) is a real type mismatch no `String` field can absorb, and
60
+ that half stayed a document-level `PinmuxParseError` -- one malformed
61
+ sequence/mapping field still fails the whole table, not just that row.
62
+
63
+ **Text mode renders every issue, not just the summary line -- a DELIBERATE
64
+ divergence from the oracle (tan-cli#458).** `crates/tan-cli/src/commands/
65
+ pinmux.rs`'s own `text` build (its lines 151-159) is exactly
66
+ `vec![format!("pinmux: family={} pads={}", ...)]` and nothing else -- an
67
+ exit-2 `pinmux.table-empty`/`pinmux.family-invalid`/
68
+ `pinmux.schema-version-unsupported` reaches an oracle terminal as a bare
69
+ `pads=0`, with the reason visible only in `--format json`. This port instead
70
+ follows the summary line with one `f"pinmux: {issue.message}"` line per issue
71
+ on stderr, matching the text-mode convention `generate_cmd`/`monitor_cmd`/
72
+ `build_cmd`/`doctor_cmd`/`examples_cmd` already use for THEIR issues (not
73
+ every sibling command's: `size_cmd`/`image_cmd` build a separate text list
74
+ that drops issues from it entirely, which is their own gap, not a pattern to
75
+ match).
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ from dataclasses import dataclass
81
+ from pathlib import Path
82
+ from typing import Any
83
+
84
+ import typer
85
+
86
+ from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk
87
+ from tan.core.fs_confine import PathEscapeError, resolve_confined
88
+ from tan.core.sdk_discovery import ActiveSdk, with_sdk_search
89
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
90
+ from tan.exit_codes import ExitCode
91
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
92
+ from tan.core.shapes import rejected_sdk_root_message, yaml_kind
93
+
94
+ #: `data.schemaVersion` for this command's payload.
95
+ DATA_SCHEMA_VERSION = "1"
96
+
97
+ #: The only `schemaVersion` a `metadata/pinmux/<family>.yaml` may declare
98
+ #: (`pinmux-capability-v1.schema.json`).
99
+ SCHEMA_VERSION = "pinmux-capability-v1"
100
+
101
+ #: `sku` prefix -> pinmux family stem (`metadata/pinmux/<stem>.yaml`), checked
102
+ #: in order -- verbatim from `tan_core::pinmux::pinmux_family_for_sku`. E1M-V2M
103
+ #: reuses the base V2N pinout in full (`metadata/e1m_modules/v2n-m1/README.md`)
104
+ #: -- there is no separate `v2n-m1.yaml` table, so it maps to `"v2n"` too.
105
+ _FAMILY_PREFIX_TABLE = (
106
+ ("E1M-AEN", "aen"),
107
+ ("E1M-V2N", "v2n"),
108
+ ("E1M-V2M", "v2n"),
109
+ )
110
+
111
+
112
+ def pinmux_family_for_sku(sku: str) -> str | None:
113
+ """The pinmux family stem for `sku`'s prefix, or `None` for an
114
+ unrecognized SKU."""
115
+ for prefix, stem in _FAMILY_PREFIX_TABLE:
116
+ if sku.startswith(prefix):
117
+ return stem
118
+ return None
119
+
120
+
121
+ def _is_safe_family_stem(family: str) -> bool:
122
+ """True when `family` is a plain `metadata/pinmux/<stem>.yaml` stem: ASCII
123
+ letters/digits/`-`/`_`, non-empty (tan-cli#359).
124
+
125
+ A CHARSET allowlist rather than a shape blocklist, the same trade
126
+ `tan.core.flash_plan.validate_identifier` already makes in this tree for
127
+ the same reason: every shape a hand-written blocklist would have to
128
+ enumerate carries a character this charset already rejects -- a separator
129
+ (`/` AND `\\`, checked in the raw string on EVERY host, because pathlib on
130
+ POSIX does not treat `\\` as one, so an `os.sep`-based check would protect
131
+ Linux and leave Windows open), a Windows drive or UNC prefix (`:`), a `.`
132
+ or `..` component (`.`), a NUL or a newline. Deliberately tighter than
133
+ `tan_core::path_guard::is_plain_relative`, which is a check for a plain
134
+ RELATIVE PATH: `a/b` passes that and is still not a family stem, and on
135
+ POSIX it also accepts `C:\\x` and `..\\..\\x` as ordinary filenames.
136
+
137
+ No dot is admitted because no `metadata/pinmux/*.yaml` stem has ever
138
+ contained one (`aen`, `v2n`); admitting one to be liberal would
139
+ buy nothing and hand back the `.`/`..` component this rejects outright.
140
+ """
141
+ return bool(family) and all(c.isascii() and (c.isalnum() or c in "-_") for c in family)
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class PinmuxPad:
146
+ e1m_pad: str
147
+ e1m_function: str
148
+ owner: str
149
+ silicon_peripheral: str
150
+ silicon_pad: str
151
+
152
+ def as_dict(self) -> dict[str, str]:
153
+ return {
154
+ "e1mPad": self.e1m_pad,
155
+ "e1mFunction": self.e1m_function,
156
+ "owner": self.owner,
157
+ "siliconPeripheral": self.silicon_peripheral,
158
+ "siliconPad": self.silicon_pad,
159
+ }
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class PinmuxTable:
164
+ family: str
165
+ display_name: str | None
166
+ pads: list[PinmuxPad]
167
+
168
+
169
+ class PinmuxParseError(Exception):
170
+ """The pinmux capability document itself did not parse, or its
171
+ `schemaVersion` is not `pinmux-capability-v1` -- the two `Err` cases
172
+ `parse_pinmux_table_checked` distinguishes from an ordinary fail-soft
173
+ dropped row (see the module docstring)."""
174
+
175
+
176
+ #: tan-cli#408: one implementation, in `tan.core.shapes` -- `diff_cmd.py`
177
+ #: carried the same body with a docstring this copy had lost.
178
+ _yaml_kind = yaml_kind
179
+
180
+
181
+ def _pad_field(row: dict, field: str) -> str | None:
182
+ """`row[field]` coerced to its YAML-spelled string, or `None` when the
183
+ key is absent/null. Raises `PinmuxParseError` for a sequence/mapping
184
+ value -- the one shape a `String` pad field can never absorb; every
185
+ OTHER scalar (bool/int/float, in addition to an actual string) takes its
186
+ YAML-spelled text instead, matching the oracle's own `String`-field
187
+ coercion (measured: `owner: 7` -> `"7"`, `silicon_pad: true` -> `"true"`,
188
+ both at exit 0 -- see the module docstring)."""
189
+ value = row.get(field)
190
+ if value is None:
191
+ return None
192
+ if isinstance(value, (list, dict)):
193
+ raise PinmuxParseError(f"pads[].{field}: expected a string, got {_yaml_kind(value)}")
194
+ if isinstance(value, bool):
195
+ return "true" if value else "false"
196
+ if isinstance(value, str):
197
+ return value
198
+ return str(value)
199
+
200
+
201
+ def parse_pinmux_table_checked(text: str) -> PinmuxTable:
202
+ """Parse a `pinmux-capability-v1` YAML document. Raises `PinmuxParseError`
203
+ for a document that does not parse, is not a mapping, or does not declare
204
+ the exact `schemaVersion` this parser accepts. Individual pad rows fail
205
+ soft per the module docstring."""
206
+ try:
207
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
208
+ except ImportError as err:
209
+ raise PinmuxParseError(
210
+ "this build of tan has no YAML support installed, so the pinmux table cannot "
211
+ "be read."
212
+ ) from err
213
+ try:
214
+ raw = yaml.safe_load(text)
215
+ except Exception as err: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises
216
+ raise PinmuxParseError(f"could not be parsed: {err}") from err
217
+
218
+ if not isinstance(raw, dict):
219
+ raw = {}
220
+ schema_version = raw.get("schemaVersion")
221
+ if schema_version != SCHEMA_VERSION:
222
+ raise PinmuxParseError(
223
+ f"unsupported pinmux capability schemaVersion {schema_version!r} "
224
+ f"(expected {SCHEMA_VERSION!r})"
225
+ )
226
+
227
+ raw_pads = raw.get("pads")
228
+ if raw_pads is not None and not isinstance(raw_pads, list):
229
+ raise PinmuxParseError(f"pads: expected a sequence, got {_yaml_kind(raw_pads)}")
230
+
231
+ pads: list[PinmuxPad] = []
232
+ for row in raw_pads or []:
233
+ if not isinstance(row, dict):
234
+ raise PinmuxParseError(f"pads[]: expected a mapping, got {_yaml_kind(row)}")
235
+ e1m_pad = _pad_field(row, "e1m_pad")
236
+ e1m_function = _pad_field(row, "e1m_function")
237
+ if e1m_pad is None or e1m_function is None:
238
+ continue # `p.e1m_pad?`/`p.e1m_function?` -- missing key, drop the row
239
+ if e1m_pad == "TBD":
240
+ continue # sentinel: no E1M edge ball for this silicon pad
241
+ pads.append(
242
+ PinmuxPad(
243
+ e1m_pad=e1m_pad,
244
+ e1m_function=e1m_function,
245
+ owner=_pad_field(row, "owner") or "",
246
+ silicon_peripheral=_pad_field(row, "silicon_peripheral") or "",
247
+ silicon_pad=_pad_field(row, "silicon_pad") or "",
248
+ )
249
+ )
250
+
251
+ family = raw.get("family")
252
+ display_name = raw.get("display_name")
253
+ return PinmuxTable(
254
+ family=family if isinstance(family, str) else "",
255
+ display_name=display_name if isinstance(display_name, str) else None,
256
+ pads=pads,
257
+ )
258
+
259
+
260
+ _ResolveResult = tuple[
261
+ ActiveSdk | None, str | None, str | None, list[PinmuxPad], list[Issue], ExitCode
262
+ ]
263
+
264
+
265
+ def _resolve(
266
+ sku: str | None, family: str | None, sdk_root: str | None, root: str
267
+ ) -> _ResolveResult:
268
+ """`(sdk, resolved_family, display_name, pads, issues, exit_code)` -- the
269
+ whole family/table resolution, isolated from `pinmux()` so the command's
270
+ own top-level `try`/`except` can wrap it once (matching `presets_cmd`'s
271
+ own catch-all convention: an exception nobody enumerated must still reach
272
+ the caller as one coded issue, never a bare traceback with an empty
273
+ stdout).
274
+ """
275
+ issues: list[Issue] = []
276
+
277
+ # Family resolution: explicit `--family` wins; else map `--sku` by prefix.
278
+ # `--family` never even evaluates whether `--sku` maps -- no unknown-sku
279
+ # warning fires when `--family` is also given (measured against the
280
+ # oracle: `--sku E1M-BOGUS --family v2n` reports family "v2n", no
281
+ # `pinmux.unknown-sku` issue).
282
+ resolved_family: str | None
283
+ if family is not None:
284
+ resolved_family = family
285
+ elif sku is not None:
286
+ resolved_family = pinmux_family_for_sku(sku)
287
+ if resolved_family is None:
288
+ issues.append(
289
+ Issue(
290
+ "pinmux.unknown-sku",
291
+ "warning",
292
+ f"SKU '{sku}' maps to no known pinmux family.",
293
+ )
294
+ )
295
+ else:
296
+ resolved_family = None
297
+ issues.append(
298
+ Issue("pinmux.no-target", "warning", "Provide --sku <sku> or --family <family>.")
299
+ )
300
+
301
+ exit_code = ExitCode.SUCCESS
302
+ sdk = resolve_sdk(sdk_root, root)
303
+ display_name: str | None = None
304
+ pads: list[PinmuxPad] = []
305
+
306
+ # tan-cli#359. `--family` is caller-controlled and reaches this join
307
+ # unvalidated on the oracle: an ABSOLUTE value discards the SDK prefix
308
+ # entirely (`Path("/sdk-a") / "/sdk-b/metadata/pinmux/aen"` IS
309
+ # `/sdk-b/...`, same as Rust's `Path::join`) and `..` walks out of it, so
310
+ # the envelope reported `sdkRoot` = the checkout it never read. Checked
311
+ # here, at the ONE place a family becomes a path, so the `--sku` route is
312
+ # covered by construction too -- and BEFORE `sdk`/`resolved_family` are
313
+ # even paired, so a rejected family never reaches the read.
314
+ #
315
+ # TWO INDEPENDENT checks, because each has a hole the other covers: the
316
+ # stem charset cannot see a SYMLINK planted inside `metadata/pinmux/`
317
+ # (`aen.yaml` -> elsewhere is a perfectly plain stem), and a containment
318
+ # re-check alone would wave through a `../pinmux/aen`-shaped family that
319
+ # merely happens to land back inside. Exactly one coded issue either way;
320
+ # nothing else can have been appended yet at the first check (`--family`
321
+ # short-circuits the `--sku` lookup, and `pinmux.no-target` only fires
322
+ # when `resolved_family is None`).
323
+ if resolved_family is not None and not _is_safe_family_stem(resolved_family):
324
+ issues.append(
325
+ Issue(
326
+ "pinmux.family-invalid",
327
+ "error",
328
+ f"Pinmux family '{resolved_family}' is not a plain family stem "
329
+ "(ASCII letters, digits, '-' and '_' only) -- refusing to read a "
330
+ "table from outside <sdkRoot>/metadata/pinmux.",
331
+ )
332
+ )
333
+ return sdk, resolved_family, None, [], issues, ExitCode.VALIDATION_FAILURE
334
+
335
+ # tan-cli#468: `resolve_sdk` now always returns an `ActiveSdk`, never a
336
+ # bare `None` -- `sdk.path` is what says whether a usable checkout
337
+ # resolved, so both branches below guard on that instead of `sdk`'s own
338
+ # (now-unconditional) truthiness. `Path(sdk.path)` would otherwise raise
339
+ # on a `None` the moment `resolved_family` alone gated this branch.
340
+ if sdk.path is not None and resolved_family is not None:
341
+ table_dir = Path(sdk.path) / "metadata" / "pinmux"
342
+ try:
343
+ # `resolve_confined` resolves BOTH sides and compares components,
344
+ # so it is correct where a `str.startswith` prefix test is not
345
+ # (Windows case folding, `C:\proj` vs `C:\project2`) -- the same
346
+ # shared guard `init`/`generate`/`scaffold` use, not a fourth
347
+ # hand-rolled copy of the predicate. `OSError`/`ValueError` are
348
+ # caught per its own docstring: a path shape the host rejects
349
+ # outright (a Windows device-namespace path, one embedding a NUL)
350
+ # raises there and is a refusal just the same.
351
+ table_path = resolve_confined(table_dir, table_dir / f"{resolved_family}.yaml")
352
+ except (PathEscapeError, OSError, ValueError):
353
+ issues.append(
354
+ Issue(
355
+ "pinmux.family-invalid",
356
+ "error",
357
+ f"Pinmux capability table for family '{resolved_family}' resolves "
358
+ "outside <sdkRoot>/metadata/pinmux -- refusing to read it.",
359
+ )
360
+ )
361
+ return sdk, resolved_family, None, [], issues, ExitCode.VALIDATION_FAILURE
362
+ try:
363
+ text = table_path.read_text(encoding="utf-8")
364
+ except (OSError, UnicodeDecodeError):
365
+ # tan-cli#415: `UnicodeDecodeError` is a `ValueError`, not an
366
+ # `OSError`, so `except OSError` alone let a non-UTF-8 pinmux
367
+ # table escape `_resolve` uncaught -- caught upstream only by
368
+ # `pinmux()`'s generic backstop, which reported it as an `error`-
369
+ # severity `pinmux.internal-failure` at a non-zero exit instead of
370
+ # this SAME `warning`-severity "I don't know" every other
371
+ # unreadable table already gets at exit 0.
372
+ issues.append(
373
+ Issue(
374
+ "pinmux.table-not-found",
375
+ "warning",
376
+ f"No pinmux capability table for family '{resolved_family}' "
377
+ f"(metadata/pinmux/{resolved_family}.yaml).",
378
+ )
379
+ )
380
+ else:
381
+ try:
382
+ table = parse_pinmux_table_checked(text)
383
+ except PinmuxParseError as err:
384
+ issues.append(
385
+ Issue(
386
+ "pinmux.schema-version-unsupported",
387
+ "error",
388
+ f"Pinmux capability table for family '{resolved_family}' failed to "
389
+ f"parse (metadata/pinmux/{resolved_family}.yaml): {err}",
390
+ )
391
+ )
392
+ exit_code = ExitCode.VALIDATION_FAILURE
393
+ else:
394
+ display_name = table.display_name
395
+ pads = table.pads
396
+ if not pads:
397
+ # `pinmux-capability-v1.schema.json` requires `minItems: 1`:
398
+ # a successful parse of a real v1 table is never
399
+ # legitimately empty.
400
+ issues.append(
401
+ Issue(
402
+ "pinmux.table-empty",
403
+ "error",
404
+ f"Pinmux capability table for family '{resolved_family}' parsed "
405
+ f"with zero pads (metadata/pinmux/{resolved_family}.yaml).",
406
+ )
407
+ )
408
+ exit_code = ExitCode.VALIDATION_FAILURE
409
+ elif sdk.path is None:
410
+ # tan-cli#497 defect 7: when `--sdk-root` WAS given and the loader-marker
411
+ # check rejected it, name the value. The bare string below is the answer
412
+ # for BOTH cases today, so a typo'd flag read as "alp-sdk root is
413
+ # unresolved" with the path the user had just typed nowhere in the
414
+ # envelope (`data.sdkRoot` is `null` on this branch) and nowhere in the
415
+ # stderr text either.
416
+ issues.append(
417
+ Issue(
418
+ "pinmux.sdk-root-unresolved",
419
+ "warning",
420
+ rejected_sdk_root_message(sdk_root, "Cannot read the pinmux table.")
421
+ if sdk_root
422
+ else with_sdk_search(
423
+ "alp-sdk root is unresolved; cannot read the pinmux table.", root
424
+ ),
425
+ )
426
+ )
427
+
428
+ return sdk, resolved_family, display_name, pads, issues, exit_code
429
+
430
+
431
+ def pinmux(
432
+ ctx: typer.Context,
433
+ project: str = typer.Option(
434
+ None, "--project", metavar="PATH", help="Project root (defaults to current directory)."
435
+ ),
436
+ sku: str = typer.Option(
437
+ None,
438
+ "--sku",
439
+ # tan-cli#720: `--som` accepted as an alias -- `tan init` spells this
440
+ # same value `--som`, and being rejected here for the flag `init` just
441
+ # taught is the trap this closes.
442
+ "--som",
443
+ metavar="SKU",
444
+ help="SoM SKU to resolve the pinmux family from (e.g. `E1M-AEN701`) (alias: --som).",
445
+ ),
446
+ board_yaml: str = typer.Option(
447
+ None,
448
+ "--board-yaml",
449
+ metavar="PATH",
450
+ help="Explicit board.yaml path (overrides project resolution).",
451
+ ),
452
+ family: str = typer.Option(
453
+ None,
454
+ "--family",
455
+ metavar="FAMILY",
456
+ help="Pinmux family stem directly (e.g. `aen`, `v2n`); overrides `--sku`.",
457
+ ),
458
+ sdk_root: str = typer.Option(
459
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
460
+ ),
461
+ target: str = typer.Option( # accepted, not read
462
+ None,
463
+ "--target",
464
+ metavar="EMIT",
465
+ help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).",
466
+ ),
467
+ all_targets: bool = typer.Option( # accepted, not read
468
+ False, "--all", help="Run command against all relevant targets."
469
+ ),
470
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
471
+ verbose: bool = typer.Option( # accepted, not read
472
+ False, "--verbose", help="Emit additional diagnostic detail."
473
+ ),
474
+ quiet: bool = typer.Option( # accepted, not read; pinmux's text line is unconditional
475
+ False, "--quiet", help="Suppress non-essential output."
476
+ ),
477
+ no_color: bool = typer.Option( # accepted, not read; pinmux emits no ANSI color
478
+ False, "--no-color", help="Disable ANSI color in text output."
479
+ ),
480
+ non_interactive: bool = typer.Option( # accepted, not read; pinmux never prompts
481
+ False, "--non-interactive", help="Never prompt."
482
+ ),
483
+ ci: bool = typer.Option( # accepted, not read
484
+ False, "--ci", help="CI mode: implies non-interactive and disables color."
485
+ ),
486
+ ) -> None:
487
+ """Show the E1M pinmux capability table (E1M pad -> silicon function) for
488
+ a SoM family.
489
+
490
+ `--target`/`--all`/`--verbose`/`--quiet`/`--no-color`/`--non-interactive`/
491
+ `--ci` are declared, not consumed: `pinmux` reads only `--sku`/`--family`
492
+ plus the resolved SDK root (`crates/tan-cli/src/commands/pinmux.rs` never
493
+ touches `GlobalArgs::target`/`all`/`verbose`/`quiet`), but the oracle's
494
+ clap `GlobalArgs` are `global = true`, so every verb accepts all of them.
495
+ """
496
+ del target, all_targets, verbose, quiet, no_color, non_interactive, ci
497
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
498
+ json_mode = resolved_format == "json"
499
+
500
+ root, board_path = resolve_project_paths(project, board_yaml)
501
+ try:
502
+ sdk, resolved_family, display_name, pads, issues, exit_code = _resolve(
503
+ sku, family, sdk_root, root
504
+ )
505
+ except Exception as err: # noqa: BLE001 -- the envelope IS the error contract
506
+ sdk, resolved_family, display_name, pads = None, family, None, []
507
+ issues = [
508
+ Issue(
509
+ "pinmux.internal-failure",
510
+ "error",
511
+ f"pinmux failed unexpectedly: {err.__class__.__name__}: {err}",
512
+ )
513
+ ]
514
+ exit_code = ExitCode.INTERNAL_FAILURE
515
+
516
+ data: dict[str, Any] = {
517
+ "schemaVersion": DATA_SCHEMA_VERSION,
518
+ "sdkRoot": sdk.path if sdk is not None else None,
519
+ }
520
+ if sku is not None:
521
+ data["sku"] = sku
522
+ data["family"] = resolved_family
523
+ if display_name is not None:
524
+ data["displayName"] = display_name
525
+ data["pads"] = [p.as_dict() for p in pads]
526
+
527
+ # Built ONCE, for both formats: `Envelope.__init__` appends the
528
+ # tan-cli#407 `sdk.discovery-divergent` warning to a NEW issues list
529
+ # (`_with_sdk_divergence`) rather than mutating the one passed in, so a
530
+ # text branch that kept iterating the pre-envelope `issues` local (as
531
+ # this one used to) would build its summary+issues lines correctly for
532
+ # every issue `_resolve` raised, but silently never print a divergence
533
+ # warning appended at the envelope seam -- reproduced live: two alp-sdk
534
+ # checkouts resolving from one directory with no `--sku`/`--family` at
535
+ # all printed only `pinmux: family=- pads=0` in text mode while the JSON
536
+ # envelope carried `sdk.discovery-divergent` alongside `pinmux.no-target`.
537
+ envelope = Envelope(
538
+ "pinmux",
539
+ Project.resolved(root, board_path),
540
+ data,
541
+ issues,
542
+ exit_code,
543
+ # tan-cli#468: `sdk` (from `_resolve`) is now a real `ActiveSdk` even
544
+ # when nothing usable resolved -- `.path` is what says whether there
545
+ # is a checkout to report, `sdk is not None` alone no longer does.
546
+ sdk=SdkInfo.from_resolution(sdk.path, sdk)
547
+ if sdk is not None and sdk.path is not None
548
+ else None,
549
+ )
550
+ if json_mode:
551
+ emit(envelope)
552
+ else:
553
+ stream = typer.get_text_stream("stderr")
554
+ stream.write(f"pinmux: family={resolved_family or '-'} pads={len(pads)}\n")
555
+ # tan-cli#458: the summary line above answers "what happened" but never
556
+ # "why" -- an exit-2 `pinmux.table-empty`/`pinmux.family-invalid`/
557
+ # `pinmux.schema-version-unsupported` used to print a plausible
558
+ # `pads=0` and nothing else, with the envelope's own issue never
559
+ # reaching a terminal. `generate_cmd`/`monitor_cmd`/`build_cmd`/
560
+ # `doctor_cmd`/`examples_cmd` already render issues in text mode this
561
+ # same way, one `f"{command}: {issue.message}"` line per issue on
562
+ # stderr -- pinmux was an outlier among THOSE that built a summary
563
+ # line and stopped (`size_cmd`/`image_cmd` are a separate, still-open
564
+ # gap: they build a distinct text list that drops issues from it
565
+ # entirely, out of scope here).
566
+ for issue in envelope.issues:
567
+ stream.write(f"pinmux: {issue.message}\n")
568
+ raise typer.Exit(int(exit_code))