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,736 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan migrate` / `tan lock` / `tan quality` -- the three `west`-delegating
3
+ extension commands that survive ADR-0020 Phase 4 (`west alp-build` itself was
4
+ retired; `tan build` is native now -- see `build_cmd.py`'s module doc). Port of
5
+ `crates/tan-cli/src/commands/build/workspace.rs::run` (the oracle's own,
6
+ now-narrowed, "legacy west forward" entry) and its `west_argv`/
7
+ `west_workspace_dir`/`west_program` helpers.
8
+
9
+ Every flag NOT declared on the command below (`--workspace`, `--no-verify`,
10
+ `--json`, `--junit`, `--sarif`, ...) belongs to the underlying `west
11
+ alp-<verb>` extension command, not to `tan` -- it is forwarded verbatim,
12
+ mirroring the oracle's own `[ARGS]...` catch-all
13
+ (`crates/tan-cli/src/cli.rs`'s `WestForwardArgs`). `ignore_unknown_options` in
14
+ each command's `context_settings` is what lets an unrecognised flag fall
15
+ through to that catch-all instead of Click rejecting it outright. `--check`/
16
+ `--preview`/`--apply` (`migrate`) and `--profile` (`quality`) used to be in
17
+ that undeclared set too, until tan-cli#454 (see below) made them real,
18
+ declared options for the one reason a flag in this file ever needs to be:
19
+ each is REQUIRED by its child with no default `tan` could supply.
20
+
21
+ Four defects this module shipped with, and what each fix costs:
22
+
23
+ **tan-cli#391 -- no child takes a positional.** `_run_forward` used to insert
24
+ the resolved app path as `argv[1]` whenever every forwarded token began with a
25
+ dash. `west alp-build` was the only `alp-*` command that ever declared an
26
+ app-path positional, and ADR-0020 Phase 4 retired it; the injection outlived
27
+ its target. The three surviving children declare flags only -- `alp_lock.py`:
28
+ `--check`, `--workspace`, `--board`; `alp_migrate.py`: `--check`, `--preview`,
29
+ `--apply`, `--all`, `--board`, `--no-verify`; `alp_quality.py`: `--profile`,
30
+ `--json`, `--junit`, `--sarif` -- so every realistic `tan migrate` / `tan lock`
31
+ run died on `west alp-migrate: error: unexpected arguments: ['<project>']`
32
+ before doing any work. `tan quality --profile quick` survived only by
33
+ accident (`quick` is a non-dash token, so the guard suppressed the injection);
34
+ `--profile=quick` is one token and did not. Deleting the injection alone is
35
+ NOT the whole fix: `run_cwd` is the west TOPDIR, so a child left to its own
36
+ `Path("board.yaml")` fallback resolves the WORKSPACE ROOT's board.yaml rather
37
+ than the project's. `_target_args` below names the project through the
38
+ `--board` flag `alp-migrate`/`alp-lock` really declare. `alp-quality` declares
39
+ no target flag at all and is therefore left cwd-targeted, exactly as before.
40
+
41
+ **tan-cli#395 -- the child's output was captured and discarded.** JSON mode
42
+ already spawned the child with `capture_output=True` and then read neither
43
+ stream; the previous version of this docstring defended that as "never the
44
+ child's own stdout, which for an interactive tool cannot be captured without
45
+ breaking it", but the capture had already happened one line above -- the only
46
+ decision actually being made was to throw the result away. `alp-quality`
47
+ exists to produce a report, `alp-migrate --preview` a diff, `alp-lock` a
48
+ resolved manifest, and a JSON-only caller could obtain none of them. `data`
49
+ now carries `stdout`, `stderr` and `westExitCode`, and the `<verb>.failed`
50
+ message is derived from the child's own stderr instead of a fixed sentence
51
+ telling the caller to re-run in a mode it does not have. The envelope's own
52
+ `exitCode` deliberately does NOT propagate the child's: `tan/exit_codes.py`
53
+ fixes tan's vocabulary (0/1/2/3/4/5), so a child's `2` would arrive
54
+ indistinguishable from tan's own `VALIDATION_FAILURE`. `data.westExitCode` is
55
+ what makes a lock conflict (`7`) distinguishable from west's own usage error.
56
+ This is a DELIBERATE divergence from the oracle, which calls `cmd.output()`
57
+ and never reads `out.stdout`/`out.stderr` either
58
+ (`crates/tan-cli/src/commands/build/workspace.rs`).
59
+
60
+ **tan-cli#405 -- tan's own globals leaked into the child.** These were the
61
+ only three commands registered with `ignore_unknown_options` AND the only
62
+ three that never called `accept_global_flags`, so seven of the ten flags in
63
+ `tan.core.global_flags._GLOBAL_FLAG_SPECS` were unknown here -- and an unknown
64
+ flag under `ignore_unknown_options` is not rejected, it is dropped into the
65
+ `ARGS...` catch-all and handed to a child that declares none of them.
66
+ `tan --ci quality --profile quick` became `west alp-quality --ci --profile
67
+ quick`; `tan --target cm33 quality` leaked the VALUE `cm33`, which then read
68
+ as a caller-supplied positional and silently changed the argv shape. All three
69
+ now call `accept_global_flags`. `--all` is the one collision: it is both a tan
70
+ global and a real `alp-migrate` flag, so `migrate` declares it for real and
71
+ forwards it back to the child after consuming it -- a bare
72
+ `accept_global_flags(migrate)` would swallow it, which is what the v0.4.1
73
+ oracle does.
74
+
75
+ **tan-cli#454 -- `quality` and `migrate` could never succeed.**
76
+ `alp_quality.py` declares `--profile {quick,pr,full,release}` REQUIRED;
77
+ `alp_migrate.py` declares a REQUIRED mutually-exclusive `(--check | --preview
78
+ | --apply)` group. Neither was ever declared on tan's own Typer surface --
79
+ both fell into the `ARGS...` catch-all like every other undeclared flag, which
80
+ happened to still work for a caller who typed the exact right token, but meant
81
+ `tan quality --help` never named `--profile` and `tan migrate --help` never
82
+ named any of the three modes, so a caller relying on `--help` (the entire
83
+ point of a documented CLI surface) had no way to discover what to pass. On a
84
+ correctly bootstrapped workspace, every invocation that omitted the required
85
+ flag reached `west`, which rejected it with its own argparse usage error --
86
+ forwarded back as the opaque `quality.failed` / `migrate.failed`, indistinguishable
87
+ from `west` itself failing for an unrelated reason. Fixed by declaring
88
+ `--profile` (`quality`) and `--check`/`--preview`/`--apply` (`migrate`) for
89
+ real -- their choice lists and mutual-exclusion read from
90
+ `scripts/west_commands/alp_quality.py` / `alp_migrate.py` verbatim, forwarded
91
+ back through `extra_args` exactly like `migrate`'s pre-existing `--all`
92
+ (tan-cli#405) -- and refusing EARLY, before `west` is ever spawned, with a
93
+ coded `quality.profile-required` / `migrate.mode-required` issue naming the
94
+ missing flag when none of the required ones was supplied. See
95
+ `_refuse_required`.
96
+
97
+ Text mode inherits stdio -- `alp-migrate`/`alp-lock`/`alp-quality` may prompt
98
+ or stream their own progress, exactly like the oracle's `west`-delegating path
99
+ (its own module doc: "Text mode inherits stdio so the build streams live"), so
100
+ there is nothing to capture there and nothing to report but the child's exit
101
+ code. JSON mode captures and reports ONE envelope.
102
+ """
103
+ from __future__ import annotations
104
+
105
+ import os
106
+ import subprocess
107
+ from collections.abc import Sequence
108
+ from dataclasses import dataclass
109
+ from pathlib import Path
110
+ from typing import NoReturn
111
+
112
+ import typer
113
+
114
+ from tan.commands.build_output import ProjectContext, resolve_project_context, to_posix
115
+ from tan.core.global_flags import accept_global_flags
116
+ from tan.core.sdk_discovery import sdk_resolution_issues
117
+ from tan.core.subprocess_env import spawn_env
118
+ from tan.core.venv import west_program, west_workspace_dir, with_venv_on_path
119
+ from tan.envelope import Envelope, Issue, emit
120
+ from tan.exit_codes import ExitCode
121
+ from tan.output_format import FORMAT_HELP, OutputFormat
122
+
123
+ #: Passed to `app.command(...)` for each forwarder below: lets an unrecognised
124
+ #: flag (the underlying `west alp-*` command's own surface) fall through to the
125
+ #: `args` catch-all instead of Click's usual "no such option" usage error.
126
+ FORWARD_CONTEXT_SETTINGS = {"ignore_unknown_options": True}
127
+
128
+ #: Kept as the underscore-named alias this module (and its own tests) always
129
+ #: called it -- the implementation moved to `tan.core.venv.west_workspace_dir`
130
+ #: (tan-cli#289 review) so `flash_cmd.py` shares it instead of importing a
131
+ #: private name across modules.
132
+ _west_workspace_dir = west_workspace_dir
133
+
134
+ #: The verbs whose child declares `--board` (tan-cli#391). `quality` is absent
135
+ #: on purpose: `alp_quality.py`'s surface is `--profile`/`--json`/`--junit`/
136
+ #: `--sarif` and nothing else, so there is no flag through which tan could name
137
+ #: a project and inventing one would be a usage error, not targeting.
138
+ _BOARD_TARGETED = frozenset({"migrate", "lock"})
139
+
140
+ #: How much of the child's stderr the `<verb>.failed` message may quote
141
+ #: (tan-cli#395). `data.stderr` keeps the stream VERBATIM and uncapped -- it is
142
+ #: the payload; this caps only the one-line summary, so a child that dumps a
143
+ #: megabyte on one line cannot turn a single issue into an unreadable envelope.
144
+ _MAX_MESSAGE_DETAIL = 500
145
+
146
+ #: Verbatim copy of `scripts/west_commands/alp_quality.py`'s own
147
+ #: `choices=("quick", "pr", "full", "release")` (tan-cli#454) -- read from the
148
+ #: SDK checkout the resolver points at, not guessed. Kept as one tuple so the
149
+ #: `--help` metavar, the `--profile <value>` validation and the "which one is
150
+ #: missing" refusal message can never drift apart from each other.
151
+ _QUALITY_PROFILES = ("quick", "pr", "full", "release")
152
+
153
+
154
+ def _west_argv(subcommand: str, passthrough: list[str]) -> list[str]:
155
+ """Port of `workspace.rs::west_argv`: `alp-<subcommand>` followed by the
156
+ forwarded args, verbatim."""
157
+ return [f"alp-{subcommand}", *passthrough]
158
+
159
+
160
+ def _launch_error(err: OSError) -> str:
161
+ """Port of `workspace.rs::west_launch_error`."""
162
+ if isinstance(err, FileNotFoundError):
163
+ return "west not found on PATH — run `tan bootstrap` and ensure west is on PATH."
164
+ return f"failed to launch west: {err}"
165
+
166
+
167
+ def _has_flag(args: Sequence[str], flag: str) -> bool:
168
+ """Whether the caller already spelled `flag` themselves, in either form
169
+ argparse accepts for a value-taking option (`--board X`, `--board=X`).
170
+
171
+ argparse's prefix ABBREVIATION (`--boa X`) is deliberately not detected:
172
+ tan's own `--board` is prepended AHEAD of the forwarded tokens, so a
173
+ caller's later spelling -- abbreviated or not -- is the one argparse keeps.
174
+ The one case where that "last wins" safety net does not apply is
175
+ `alp-migrate`'s `--all`, which is MUTUALLY EXCLUSIVE with `--board` rather
176
+ than merely overridden by it; `--all` takes no value, and `migrate`
177
+ declares it for real below, so the exact-match check is what actually runs
178
+ for it.
179
+ """
180
+ prefix = f"{flag}="
181
+ return any(arg == flag or arg.startswith(prefix) for arg in args)
182
+
183
+
184
+ def _target_args(subcommand: str, child_args: Sequence[str], board_yaml: str) -> list[str]:
185
+ """The flags that tell the child WHICH project to act on (tan-cli#391).
186
+
187
+ Empty for `quality` (no such flag exists), empty when the caller already
188
+ named a board themselves, and empty for `migrate --all` -- the child's
189
+ usage line is `[--all | --board BOARD]`, so adding `--board` behind a
190
+ caller's `--all` would trade one usage error for another.
191
+ """
192
+ if subcommand not in _BOARD_TARGETED:
193
+ return []
194
+ if _has_flag(child_args, "--board"):
195
+ return []
196
+ if subcommand == "migrate" and _has_flag(child_args, "--all"):
197
+ return []
198
+ return ["--board", board_yaml]
199
+
200
+
201
+ def _last_meaningful_line(stderr: str) -> str:
202
+ """The child's own last word. Both shapes tan-cli#395 measured put the
203
+ actionable line LAST -- argparse prints its usage block and then `west
204
+ alp-migrate: error: ...`, and west prints its own usage and then `west:
205
+ unknown command "alp-lock"; ...` -- so the last non-blank line is the one
206
+ a caller needs. The full stream is in `data.stderr` regardless, which is
207
+ what makes this a summary rather than a lossy substitute."""
208
+ lines = [line.strip() for line in stderr.splitlines() if line.strip()]
209
+ if not lines:
210
+ return ""
211
+ detail = lines[-1]
212
+ return detail if len(detail) <= _MAX_MESSAGE_DETAIL else f"{detail[:_MAX_MESSAGE_DETAIL]}..."
213
+
214
+
215
+ def _child_failure_message(west_command: str, returncode: int, stderr: str) -> str:
216
+ """tan-cli#395: the fixed "re-run without --format json to see the log"
217
+ told a JSON-only caller to use a channel it does not have, and named
218
+ neither the child's exit code nor its diagnostic. The fallback survives for
219
+ a child that fails silently -- there is genuinely nothing else to say
220
+ then."""
221
+ detail = _last_meaningful_line(stderr)
222
+ if not detail:
223
+ return (
224
+ f"`west {west_command}` failed with exit code {returncode}; the child "
225
+ "wrote nothing to stderr -- re-run without --format json to see the log."
226
+ )
227
+ return f"`west {west_command}` failed with exit code {returncode}: {detail}"
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class _Forward:
232
+ """One resolved `west alp-*` invocation, computed once and shared by both
233
+ output modes so text and JSON can never spawn different argv."""
234
+
235
+ subcommand: str
236
+ west_bin: str
237
+ #: The `alp-<verb>` argv -- `argv[0]` is the verb, the `west` binary is NOT
238
+ #: part of it (see `full_argv`).
239
+ argv: tuple[str, ...]
240
+ run_cwd: str
241
+ env: dict[str, str]
242
+
243
+ @property
244
+ def west_command(self) -> str:
245
+ return self.argv[0]
246
+
247
+ @property
248
+ def full_argv(self) -> list[str]:
249
+ return [self.west_bin, *self.argv]
250
+
251
+ def data(self) -> dict[str, object]:
252
+ """`data.args` is every argument handed to the `alp-<verb>` command --
253
+ tan's own `--board` included, tan's globals excluded (tan-cli#391/#405).
254
+ It used to echo the caller's tokens only, so the envelope could not
255
+ name the injected argument the child actually died on. `westCommand`
256
+ carries `argv[0]`, so `west <westCommand> <args...>` reconstructs the
257
+ exact child command line."""
258
+ return {
259
+ "schemaVersion": "1",
260
+ "westCommand": self.west_command,
261
+ "westCwd": self.run_cwd,
262
+ "args": list(self.argv[1:]),
263
+ }
264
+
265
+
266
+ def _plan(
267
+ subcommand: str,
268
+ passthrough: Sequence[str],
269
+ extra_args: Sequence[str],
270
+ context: ProjectContext,
271
+ ) -> _Forward:
272
+ west_cwd = context.workspace_root or "."
273
+ sdk_path = Path(context.sdk.root) if context.sdk is not None else None
274
+ west_bin = west_program(west_cwd, str(sdk_path) if sdk_path is not None else None)
275
+ workspace = _west_workspace_dir(west_cwd, sdk_path)
276
+ # `to_posix`: `str(PurePath)` renders with the platform separator (backslash
277
+ # on Windows), but Rust's `Path::to_string_lossy` merely slices the already
278
+ # forward-slash `west_cwd` string -- never re-rendering it -- so the oracle's
279
+ # `data.westCwd` stays forward-slash on every platform. Without this the
280
+ # port's envelope breaks the "platform-identical path" handshake contract
281
+ # (`tan_core::project::to_posix`) the very first time a workspace resolves.
282
+ run_cwd = to_posix(str(workspace)) if workspace is not None else to_posix(west_cwd)
283
+
284
+ child_args = [*passthrough, *extra_args]
285
+ argv = _west_argv(
286
+ subcommand, [*_target_args(subcommand, child_args, context.board_yaml), *child_args]
287
+ )
288
+ return _Forward(
289
+ subcommand=subcommand,
290
+ west_bin=west_bin,
291
+ argv=tuple(argv),
292
+ run_cwd=run_cwd,
293
+ # tan-cli#992: `spawn_env()` FIRST (the restored copy), THEN the venv
294
+ # PATH-prepend on top -- the two touch disjoint keys (`LD_LIBRARY_PATH`
295
+ # vs `PATH`) so the order never matters, but this is the one
296
+ # `env=` every caller below shares (`_Forward.env`, "computed once ...
297
+ # so text and JSON can never spawn different argv" per this
298
+ # dataclass's own docstring), so it is built correctly exactly once.
299
+ env=with_venv_on_path(spawn_env(), west_bin),
300
+ )
301
+
302
+
303
+ def _spawn_captured(plan: _Forward) -> subprocess.CompletedProcess[str]:
304
+ """JSON mode's child. `capture_output=True` was always here -- tan-cli#395
305
+ is that neither stream was ever READ afterwards, not that the capture was
306
+ missing. Raises `OSError` when `west` itself cannot be launched."""
307
+ return subprocess.run(
308
+ plan.full_argv,
309
+ cwd=plan.run_cwd,
310
+ # tan-cli#992: re-applied at the literal spawn (`plan.env` was
311
+ # already built via `spawn_env` in `_plan_forward` -- this is
312
+ # defense-in-depth, and idempotent).
313
+ env=spawn_env(base=plan.env),
314
+ capture_output=True,
315
+ text=True,
316
+ encoding="utf-8",
317
+ errors="replace",
318
+ check=False,
319
+ )
320
+
321
+
322
+ def _sdk_resolution_issues(context: ProjectContext) -> list[Issue]:
323
+ """The `sdk_resolution_issues` pair, computed from `context`'s own
324
+ top-level fields rather than `context.sdk` (tan-cli#958). `size`/`image`/
325
+ `clean` already read `context.broken_project_pin`/`context.sdk_source_tier`/
326
+ `context.foreign_global_default_for` directly for exactly this reason:
327
+ those three fields are carried on `ProjectContext` UNCONDITIONALLY --
328
+ unlike `context.sdk`, which is `None` the moment nothing resolved to a
329
+ usable checkout. Gating on `sdk is not None` (the seam's own
330
+ `_with_sdk_resolution_advisories`, and this module's own previous shape)
331
+ silently drops `sdk.project-pin-unresolved` in exactly the one case a
332
+ broken pin matters most: when the ladder falls all the way through and
333
+ NOTHING backstops it. `Envelope.__init__`'s seam still runs this same pair
334
+ off `context.sdk` when `context.sdk` is not `None` -- redundant then, but
335
+ deduplicated by code, so calling it here first and letting the seam run
336
+ second never double-emits.
337
+
338
+ Reads the top-level triple unconditionally, `size_cmd.py`/`image_cmd.py`'s
339
+ own established idiom, rather than branching on `context.sdk is not None`
340
+ to read the nested `SdkInfo` copy: `resolve_project_context` is the one
341
+ production construction site (`build_output.py`'s own
342
+ `ProjectContext.broken_project_pin` docstring -- "carried through") and
343
+ always writes both the nested triple and the top-level twins from the same
344
+ `active` resolution, so the two agree by construction in every real run
345
+ and a branch to pick between them is dead-equivalent there -- it only ever
346
+ mattered for a hand-built fixture that set one copy and not the other.
347
+ """
348
+ return sdk_resolution_issues(
349
+ context.broken_project_pin, context.sdk_source_tier, context.foreign_global_default_for
350
+ )
351
+
352
+
353
+ def _echo_sdk_resolution(context: ProjectContext) -> None:
354
+ """tan-cli#478/#958: the SDK-resolution pair on stderr, for the text paths.
355
+
356
+ Every `--format json` path in this module gets the pair for free from
357
+ `Envelope.__init__`'s central seam PLUS the direct `_sdk_resolution_issues`
358
+ call each JSON branch below now makes (tan-cli#958 -- the seam alone drops
359
+ the pair when nothing resolved). NO text path reaches `Envelope` at all --
360
+ neither `_run_text` (which hands stdio to the child) nor
361
+ `_refuse_required`'s refusal (which never builds an envelope) -- so both
362
+ call this instead of open-coding the loop, and this reads the same
363
+ unconditional `context` fields the JSON branches do rather than gating on
364
+ `context.sdk`, so a third text path cannot be added that discloses in JSON
365
+ and stays silent on the screen: that is precisely how `_refuse_required`
366
+ shipped silent while `_run_text` did not, and how all three shipped silent
367
+ on a broken pin with no working fallback.
368
+ """
369
+ for issue in _sdk_resolution_issues(context):
370
+ typer.echo(issue.message, err=True)
371
+
372
+
373
+ def _run_text(plan: _Forward, context: ProjectContext) -> None:
374
+ """Inherit stdio: the child streams its own progress and may prompt.
375
+
376
+ tan-cli#478 review finding 8: `_plan` resolves `west_bin`/`workspace` off
377
+ `context.sdk.root` (see `_plan`'s own `sdk_path` line), so this command
378
+ SPAWNS `west` out of the resolved SDK root -- same shape as `validate`'s
379
+ spawn, worse consequence, since the child is a real build rather than a
380
+ schema check. The JSON envelope below already discloses a foreign
381
+ `globalDefault` via `Envelope.__init__`'s central seam. Text mode never
382
+ reaches `Envelope` at all (the child inherits stdio directly), so this
383
+ is printed BEFORE the spawn -- once stdio is handed to the child, tan
384
+ has no stream left of its own to write a warning to. Takes the whole
385
+ `context` (tan-cli#958), not just `context.sdk`: a broken pin with NO
386
+ working fallback resolves `context.sdk` to `None`, and that is exactly
387
+ the case `_echo_sdk_resolution` must still disclose -- it is also the
388
+ case where `west_program`/`_west_workspace_dir` below silently fall back
389
+ to a bare `west` on PATH and the project root, so this warning is the
390
+ only signal a text-mode caller gets that the wrong `west` is about to run.
391
+ """
392
+ _echo_sdk_resolution(context)
393
+ try:
394
+ result = subprocess.run(
395
+ plan.full_argv, cwd=plan.run_cwd, env=spawn_env(base=plan.env), check=False
396
+ )
397
+ except OSError as err:
398
+ typer.echo(f"{plan.subcommand}: {_launch_error(err)}", err=True)
399
+ raise typer.Exit(int(ExitCode.RUNTIME_FAILURE)) from err
400
+ if result.returncode == 0:
401
+ typer.echo(f"{plan.subcommand}: complete.", err=True)
402
+ raise typer.Exit(0)
403
+ # The log IS above, but the code the child chose is not in it -- tan's own
404
+ # exit stays 1 on this path too (tan-cli#395), so naming the child's code is
405
+ # the only way a text-mode caller can tell a `7` from a `2`.
406
+ typer.echo(
407
+ f"{plan.subcommand}: `west {plan.west_command}` failed with exit code "
408
+ f"{result.returncode} (see log above).",
409
+ err=True,
410
+ )
411
+ raise typer.Exit(int(ExitCode.RUNTIME_FAILURE))
412
+
413
+
414
+ def _refuse_required(
415
+ subcommand: str, code: str, message: str, context: ProjectContext, output_format: OutputFormat
416
+ ) -> NoReturn:
417
+ """tan-cli#454: `alp-quality --profile` and `alp-migrate`'s one-of
418
+ `--check`/`--preview`/`--apply` are REQUIRED by the child's own argparse,
419
+ with no default tan could pick on a caller's behalf (guessing a quality
420
+ profile, or guessing whether to write board.yaml, is guessing wrong for
421
+ someone). Neither was ever declared on tan's own Typer surface, so
422
+ `tan quality --help` never named `--profile` and every realistic run
423
+ without it reached `west`, which rejected it with its OWN argparse usage
424
+ error -- forwarded back to the caller as an opaque `<verb>.failed`,
425
+ indistinguishable from `west` itself failing, and undiscoverable from
426
+ `--help`. Refusing HERE, before `west` is ever spawned, fixes both: no
427
+ child process runs, and the coded issue below names the exact missing
428
+ flag. `VALIDATION_FAILURE` (2), matching what a Click-level required
429
+ option would report, but under the REAL command name and a real code --
430
+ the same "no generic `cli.parse-error` fallback" precedent
431
+ `new_som_cmd.py`'s own `new-som.failed` sets, not `command: "cli"`.
432
+
433
+ This refusal's own `data` -- `{"schemaVersion": "1"}` only -- is
434
+ deliberately narrower than `_run_forward`'s JSON branches below, which
435
+ fill in `westCommand`/`westCwd`/`args`/`westExitCode`/`stdout`/`stderr`
436
+ for every path that actually reaches `_plan()`. No `west` invocation was
437
+ ever planned here -- there is no `westCommand` to name, no `westCwd` to
438
+ resolve, no child argv to report -- so there is nothing to backfill;
439
+ `faultdecode_cmd.py`'s own `data: None` refusal is the established
440
+ precedent for a bare payload on this exact kind of pre-spawn refusal.
441
+ Annotated `NoReturn`, not `None`: every caller ends its own function at
442
+ this call (`raise typer.Exit` always fires), and an accurate signature is
443
+ what lets a type checker prove the return type of a caller like
444
+ `_resolve_quality_profile` sound instead of just trusting the docstring.
445
+ tan-cli#958: the resolution pair below is PREPENDED (`clean_cmd.py`'s own
446
+ ordering), no longer left to `Envelope.__init__`'s `sdk`-gated seam."""
447
+ resolution_issues = _sdk_resolution_issues(context)
448
+ if output_format == "json":
449
+ emit(Envelope(
450
+ subcommand, context.project(), {"schemaVersion": "1"},
451
+ [*resolution_issues, Issue(code, "error", message)],
452
+ ExitCode.VALIDATION_FAILURE, sdk=context.sdk,
453
+ ))
454
+ else:
455
+ # tan-cli#478 (PR #504 review): this branch printed the refusal ALONE
456
+ # while the JSON branch above disclosed the SDK-resolution pair -- and
457
+ # a bare `tan quality` / `tan migrate` lands here, having resolved the
458
+ # checkout `_plan` would have run `west` out of. Order as `_run_text`.
459
+ _echo_sdk_resolution(context)
460
+ typer.echo(f"{subcommand}: {message}", err=True)
461
+ raise typer.Exit(int(ExitCode.VALIDATION_FAILURE))
462
+
463
+
464
+ def _run_forward(
465
+ subcommand: str,
466
+ passthrough: list[str],
467
+ project: str | None,
468
+ board_yaml: str | None,
469
+ sdk_root: str | None,
470
+ output_format: OutputFormat,
471
+ extra_args: Sequence[str] = (),
472
+ ) -> None:
473
+ """`extra_args` is what tan CONSUMED and hands back to the child --
474
+ `migrate`'s `--all` and nothing else today (tan-cli#405). The JSON branch
475
+ stays inline rather than moving to its own helper: both `<verb>.failed`
476
+ sites have to be reachable from one function for
477
+ `tests/gates/test_every_issue_code_is_registered.py`'s prefix-template scan
478
+ to resolve them."""
479
+ context = resolve_project_context(project, board_yaml, sdk_root)
480
+ plan = _plan(subcommand, passthrough, extra_args, context)
481
+ if output_format != "json":
482
+ _run_text(plan, context)
483
+ return
484
+ # tan-cli#958: PREPENDED below (`clean_cmd.py`'s own ordering convention)
485
+ # -- see `_sdk_resolution_issues`'s own docstring for why this reads
486
+ # `context`, not `context.sdk`.
487
+ resolution_issues = _sdk_resolution_issues(context)
488
+ try:
489
+ out = _spawn_captured(plan)
490
+ except OSError as err:
491
+ # No child ran, so there is no code to report -- `null`, not a
492
+ # fabricated 1. The keys stay present so a consumer sees ONE shape on
493
+ # every branch THAT REACHES A PLAN (tan-cli#395) -- `_refuse_required`'s
494
+ # own, earlier refusal never computes one and carries none of these
495
+ # keys; see that function's own docstring for why that is a separate,
496
+ # narrower shape rather than an omission here.
497
+ data = {**plan.data(), "westExitCode": None, "stdout": "", "stderr": ""}
498
+ exit_code = ExitCode.RUNTIME_FAILURE
499
+ issues = [*resolution_issues, Issue(f"{subcommand}.failed", "error", _launch_error(err))]
500
+ else:
501
+ # `or ""`: this is the boundary where another process's output enters
502
+ # the envelope, and a `None` would serialise as `null` for a field a
503
+ # consumer reads as text.
504
+ stdout, stderr = out.stdout or "", out.stderr or ""
505
+ data = {**plan.data(), "westExitCode": out.returncode, "stdout": stdout, "stderr": stderr}
506
+ if out.returncode == 0:
507
+ exit_code, issues = ExitCode.SUCCESS, list(resolution_issues)
508
+ else:
509
+ exit_code = ExitCode.RUNTIME_FAILURE
510
+ message = _child_failure_message(plan.west_command, out.returncode, stderr)
511
+ issues = [*resolution_issues, Issue(f"{subcommand}.failed", "error", message)]
512
+ emit(Envelope(subcommand, context.project(), data, issues, exit_code, sdk=context.sdk))
513
+ raise typer.Exit(int(exit_code))
514
+
515
+
516
+ #: Why the catch-all parameter is `west_args` and not the obvious `args`
517
+ #: (tan-cli#405): `accept_global_flags` returns a `def wrapper(*args, **kwargs)`
518
+ #: carrying a synthesised `__signature__`, but Typer resolves each parameter's
519
+ #: type through `typing.get_type_hints(callback)`, which reads the WRAPPER's own
520
+ #: `__annotations__` -- where the name `args` already means the varargs tuple.
521
+ #: A parameter named `args` therefore came back annotated `object` instead of
522
+ #: `list[str]`, and Typer aborted with `RuntimeError: Type not yet supported`
523
+ #: for every one of these three commands. The metavar is pinned to `ARGS...`
524
+ #: so nothing about the CLI surface or the help text moves with the rename.
525
+
526
+
527
+ def _resolve_migrate_extra_args(
528
+ check: bool,
529
+ preview: bool,
530
+ apply_changes: bool,
531
+ all_boards: bool,
532
+ west_args: Sequence[str],
533
+ project: str | None,
534
+ board_yaml: str | None,
535
+ sdk_root: str | None,
536
+ output_format: OutputFormat,
537
+ ) -> list[str]:
538
+ """The `alp-migrate` argv `migrate()` forwards past its own required-flag
539
+ check -- split out so `migrate()` stays a short dispatcher (the module
540
+ size gate's own per-function budget counts a docstring's lines too).
541
+ `--all` is declared on `migrate()` for real -- BOTH a tan global and an
542
+ `alp-migrate` flag (tan-cli#405); folded in here, after the mode(s), to
543
+ keep the child's own meaning.
544
+
545
+ `--check`/`--preview`/`--apply` (tan-cli#454) are a REQUIRED mutually-
546
+ exclusive group on the child's own argparse. NONE selected refuses EARLY
547
+ via `_refuse_required` -- unless `_has_flag` (see `_target_args`) finds
548
+ one already in `west_args`, the documented escape hatch
549
+ (`tan migrate -- --check`), in which case `modes` is left empty rather
550
+ than duplicating the caller's own token. MORE than one selected on tan's
551
+ own surface (`tan migrate --check --preview`) used to build the
552
+ contradictory argv itself and spawn `west`, reporting the opaque
553
+ `migrate.failed` the absence-refusal exists to remove -- refused the same
554
+ way, with the same code, naming the other failure.
555
+ """
556
+ mode_flags = (("--check", check), ("--preview", preview), ("--apply", apply_changes))
557
+ modes = [flag for flag, chosen in mode_flags if chosen]
558
+ if len(modes) > 1:
559
+ _refuse_required(
560
+ "migrate",
561
+ "migrate.mode-required",
562
+ "only one of `--check`, `--preview`, `--apply` may be given "
563
+ "(`west alp-migrate`).",
564
+ resolve_project_context(project, board_yaml, sdk_root),
565
+ output_format,
566
+ )
567
+ if not modes and not any(_has_flag(west_args, flag) for flag, _chosen in mode_flags):
568
+ _refuse_required(
569
+ "migrate",
570
+ "migrate.mode-required",
571
+ "one of `--check`, `--preview`, `--apply` is required (`west alp-migrate`).",
572
+ resolve_project_context(project, board_yaml, sdk_root),
573
+ output_format,
574
+ )
575
+ return [*modes, *(["--all"] if all_boards else [])]
576
+
577
+
578
+ def migrate(
579
+ west_args: list[str] = typer.Argument(None, metavar="ARGS..."),
580
+ project: str = typer.Option(
581
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
582
+ ),
583
+ board_yaml: str = typer.Option(
584
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
585
+ ),
586
+ sdk_root: str = typer.Option(
587
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
588
+ ),
589
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
590
+ all_boards: bool = typer.Option(
591
+ False,
592
+ "--all",
593
+ help="Migrate every board in the workspace (`west alp-migrate --all`).",
594
+ ),
595
+ check: bool = typer.Option(
596
+ False,
597
+ "--check",
598
+ help="Report version drift; nonzero on drift (`west alp-migrate --check`).",
599
+ ),
600
+ preview: bool = typer.Option(
601
+ False,
602
+ "--preview",
603
+ help="Unified diff + diagnostic-v1 JSON, no writes (`west alp-migrate --preview`).",
604
+ ),
605
+ apply_changes: bool = typer.Option(
606
+ False, "--apply", help="Rewrite board.yaml in place (`west alp-migrate --apply`)."
607
+ ),
608
+ ) -> None:
609
+ """Migrate board.yaml to the current schema (`west alp-migrate`)."""
610
+ resolved_args = list(west_args or [])
611
+ extra_args = _resolve_migrate_extra_args(
612
+ check, preview, apply_changes, all_boards, resolved_args,
613
+ project, board_yaml, sdk_root, output_format,
614
+ )
615
+ _run_forward(
616
+ "migrate",
617
+ resolved_args,
618
+ project,
619
+ board_yaml,
620
+ sdk_root,
621
+ output_format,
622
+ extra_args=extra_args,
623
+ )
624
+
625
+
626
+ def lock(
627
+ west_args: list[str] = typer.Argument(None, metavar="ARGS..."),
628
+ project: str = typer.Option(
629
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
630
+ ),
631
+ board_yaml: str = typer.Option(
632
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
633
+ ),
634
+ sdk_root: str = typer.Option(
635
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
636
+ ),
637
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
638
+ ) -> None:
639
+ """Pin/lock library dependencies (`west alp-lock`)."""
640
+ _run_forward("lock", list(west_args or []), project, board_yaml, sdk_root, output_format)
641
+
642
+
643
+ def _resolve_quality_profile(
644
+ profile: str | None,
645
+ west_args: Sequence[str],
646
+ project: str | None,
647
+ board_yaml: str | None,
648
+ sdk_root: str | None,
649
+ output_format: OutputFormat,
650
+ ) -> str | None:
651
+ """The `--profile` value `quality()` forwards past its own required-flag
652
+ check -- split out so `quality()` itself stays a short dispatcher (see
653
+ `_resolve_migrate_extra_args`'s own docstring for why this split exists).
654
+
655
+ tan-cli#454: `alp_quality.py` declares `--profile` REQUIRED with a fixed
656
+ choice list (`_QUALITY_PROFILES`, mirrored verbatim), and no default tan
657
+ could pick on a caller's behalf. Absence refuses EARLY via
658
+ `_refuse_required` -- unless `_has_flag` (see `_target_args`) finds
659
+ `--profile` already sitting in `west_args`, the documented escape hatch
660
+ (`tan quality -- --profile quick`), in which case this returns `None`
661
+ rather than refusing: `quality()` never parsed that copy, so it is not
662
+ THIS function's own value, only proof the flag already reaches `west`
663
+ unaided -- appending a second, tan-supplied `--profile` behind it would
664
+ duplicate the flag. An out-of-list VALUE (not absence) still raises the
665
+ standard Click usage error via `typer.BadParameter`, the established
666
+ pattern `new_som_cmd.py` uses for its own choice-shaped flags
667
+ (`click_type=click.Choice(...)` crashes under this repo's pinned
668
+ typer/click pair -- see that module's own comment).
669
+ """
670
+ if profile is not None and profile not in _QUALITY_PROFILES:
671
+ raise typer.BadParameter(
672
+ f"{profile!r} is not one of {', '.join(repr(c) for c in _QUALITY_PROFILES)}.",
673
+ param_hint="'--profile'",
674
+ )
675
+ if profile is None:
676
+ if _has_flag(west_args, "--profile"):
677
+ return None
678
+ _refuse_required(
679
+ "quality",
680
+ "quality.profile-required",
681
+ "`--profile` is required (`west alp-quality --profile "
682
+ f"{{{','.join(_QUALITY_PROFILES)}}}`).",
683
+ resolve_project_context(project, board_yaml, sdk_root),
684
+ output_format,
685
+ )
686
+ return profile
687
+
688
+
689
+ def quality(
690
+ west_args: list[str] = typer.Argument(None, metavar="ARGS..."),
691
+ project: str = typer.Option(
692
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
693
+ ),
694
+ board_yaml: str = typer.Option(
695
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
696
+ ),
697
+ sdk_root: str = typer.Option(
698
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
699
+ ),
700
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
701
+ profile: str = typer.Option(
702
+ None,
703
+ "--profile",
704
+ metavar="{quick,pr,full,release}",
705
+ help="Quality profile to run (`west alp-quality --profile`; REQUIRED).",
706
+ ),
707
+ ) -> None:
708
+ """Run the board.yaml quality checks (`west alp-quality`)."""
709
+ resolved_args = list(west_args or [])
710
+ profile = _resolve_quality_profile(
711
+ profile, resolved_args, project, board_yaml, sdk_root, output_format
712
+ )
713
+ _run_forward(
714
+ "quality",
715
+ resolved_args,
716
+ project,
717
+ board_yaml,
718
+ sdk_root,
719
+ output_format,
720
+ # `None`: the caller already supplied `--profile` through the
721
+ # `ARGS...` escape hatch, and it is already in `resolved_args` --
722
+ # appending it again would duplicate the flag argparse then rejects.
723
+ extra_args=["--profile", profile] if profile is not None else [],
724
+ )
725
+
726
+
727
+ # tan-cli#405: these were the last three commands not to accept the oracle's
728
+ # global set, and the only three where NOT accepting a flag meant handing it to
729
+ # a different program rather than refusing it -- `ignore_unknown_options` routes
730
+ # an unknown flag into the `ARGS...` catch-all, and that catch-all is the
731
+ # child's argv. Applied here, at the definition, for the reason
732
+ # `accept_global_flags` documents: by the time `cli.py` registers the command,
733
+ # the Click `Command` Typer built is already final.
734
+ migrate = accept_global_flags(migrate)
735
+ lock = accept_global_flags(lock)
736
+ quality = accept_global_flags(quality)