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,1183 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan clean` -- remove this project's build root, the orchestrator's state
3
+ cache, and any out-of-tree slice build dir the system manifest names.
4
+
5
+ Port of `crates/tan-cli/src/commands/clean.rs` plus the pure planning half it
6
+ delegates to (`tan_core::clean` + `tan_core::path_guard::is_unsafe_removal_target`).
7
+ Ported into ONE file deliberately: the pure part is ~50 lines with exactly one
8
+ consumer, and a separate `tan/core/clean.py` holding a four-line guard would be
9
+ an abstraction with a single call site. Every function names the Rust item it
10
+ mirrors.
11
+
12
+ **This command DELETES, so the safety rules are stricter than anywhere else in
13
+ the port:**
14
+
15
+ * Every removal candidate is screened by [`is_unsafe_removal_target`] -- the
16
+ build root included -- BEFORE any filesystem call. A candidate that IS the
17
+ project root, an ancestor of it, or a bare filesystem/drive/UNC root is
18
+ REFUSED and reported, never silently dropped and never removed. That covers
19
+ the `rm -rf $UNSET_VAR` shape: `--build-root ""` is refused outright, and a
20
+ manifest `build_dir` that resolves onto the project root (or the
21
+ `--build-root` tree), above it, or onto a directory holding a `board.yaml` is
22
+ refused, and so is one that lies inside ANOTHER project's tree (a copied or
23
+ renamed project's manifest still names the original's absolute slice dirs,
24
+ tan-cli#1516; an enclosing parent project counts as another project).
25
+ **Deliberate oracle divergence (tan-cli#1516):** v0.4.1 removes such a dir.
26
+ Do not "fix" this back in a parity sweep.
27
+ `--build-root X` names a project TREE and clean removes `<X>/build`
28
+ (tan-cli#1482), so `.` and `..` are valid trees, not refusals.
29
+ * **Deliberate oracle divergence (tan-cli#1482):** the v0.4.1 binary removes X
30
+ itself, joined onto the project root. tan cleans `<X>/build`, with X anchored
31
+ at the current directory, so the same X passed to `build` and `clean` never
32
+ touches X's sources. Do not "fix" this back in a parity sweep.
33
+ * The screen is NOT "must stay under the build root", and must not become that.
34
+ `confine_to_build_root` -- the hardened containment guard this module DOES
35
+ reuse, see [`_subsumed_by_build_root`] -- answers a different question, and
36
+ two of the three target classes the oracle removes are legitimately OUTSIDE
37
+ the build root: the app-root `.alp-build-state.json`, and an out-of-tree slice
38
+ `build_dir` such as a Yocto tmp dir. (The v0.4.1 binary removed
39
+ `../outside` for `--build-root ../outside`; tan now removes
40
+ `../outside/build`, see the divergence note above.)
41
+ Applying containment to every target would refuse two supported cases and
42
+ diverge from the oracle on a destructive command. The rule is "not
43
+ catastrophic", not "not outside" (`path_guard.rs:100-103`).
44
+ * A symlink or junction is never followed OUT of the tree. `shutil.rmtree`
45
+ refuses a link outright, and [`_remove_dir`] removes the LINK itself instead
46
+ -- so a `build/` junctioned at another directory unlinks the junction and
47
+ leaves its target intact (verified against the Rust binary, whose
48
+ `remove_dir_all` does the same on Windows).
49
+ * `--dry-run` reaches no removal call at all: [`_classify`] returns a
50
+ `would-remove` disposition and the removal arms are never entered.
51
+ * The build root is never guessed. An unresolvable SDK is exit 1
52
+ (`clean.sdk-root-not-found`) and an unsafe build root is exit 1
53
+ (`clean.unsafe-build-root`), rather than a best-effort removal of something
54
+ nearby.
55
+
56
+ **Nothing here learns a hardware fact and nothing shells the SDK.** The
57
+ checkout is probed for its loader marker (`scripts/alp_project.py`, I-31) and
58
+ otherwise untouched: removing a build directory needs no SDK, and invoking one
59
+ would give `clean` a dependency it deliberately does not have (I-32, port-spec
60
+ anti-pattern #22). The only project input beyond the arguments is
61
+ `<build_root>/system-manifest.yaml` (build_root being `<X>/build` or
62
+ `<project_root>/build`), which this project's own build wrote.
63
+
64
+ Every failure path emits a coded envelope. An escaping traceback puts nothing
65
+ parseable on stdout and the extension then renders an empty panel with no
66
+ error, so [`clean`]'s outer guard converts any unexpected exception into
67
+ `clean.internal-failure` at exit 5. Its recovery path builds the envelope from
68
+ constants only -- never a helper that can itself throw -- because a helper
69
+ called from the recovery path is how a single fault became a DOUBLE fault
70
+ elsewhere in this port.
71
+
72
+ **The manifest sweep's YAML parser is a DECLARED dependency.** `pyyaml>=6` is a
73
+ base entry in `python/pyproject.toml` `[project].dependencies` -- its own
74
+ comment there reads "`pyyaml` is load-bearing, not optional" -- and
75
+ `scripts/build_binary.sh` builds the frozen binary with
76
+ `pip install -e ".[monitor]" "pyinstaller>=6.10"`, so PyYAML ships.
77
+
78
+ This block previously recorded a KNOWN GAP claiming the opposite: that tan
79
+ declared no YAML parser, that `build_binary.sh` installed
80
+ `pip install typer rich pyinstaller`, and that the SHIPPED `tan clean`
81
+ therefore took the no-PyYAML arm and warned on every built project. All three
82
+ were false (tan-cli#574) -- the quoted recipe was real historically, `pyyaml`
83
+ entered `pyproject.toml` afterwards, and this docstring was never updated. It
84
+ sent a maintainer to spend an artefact-size budget closing a gap already closed.
85
+
86
+ [`parse_manifest_slices`] keeps its absent-parser arm anyway, because a
87
+ `--no-deps` install can still lack PyYAML: that arm REPORTS rather than
88
+ swallows, and stays a warning since `clean` never fails over a manifest. There
89
+ is deliberately NO hand-rolled fallback scanner here -- a mis-parse would name
90
+ a PATH handed to a recursive removal.
91
+ """
92
+
93
+ from __future__ import annotations
94
+
95
+ import os
96
+ import sys
97
+ from dataclasses import dataclass, field
98
+ from pathlib import Path
99
+ from typing import Any
100
+
101
+ import typer
102
+
103
+ from tan.commands.build.materialise import MaterialiseError, confine_to_build_root
104
+ from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk
105
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
106
+ from tan.core.dir_removal import (
107
+ is_link,
108
+ os_error_text,
109
+ remove_dir as _remove_dir,
110
+ )
111
+ from tan.core.dir_removal import (
112
+ _retry_after_clearing_readonly, # noqa: F401 -- re-exported for tests, see the removal block below
113
+ )
114
+ from tan.core.sdk_discovery import (
115
+ global_default_foreign_project_issue,
116
+ project_pin_issue,
117
+ resolve_sdk_root_ladder,
118
+ sdk_search_summary,
119
+ )
120
+ from tan.core.shapes import SDK_MARKER
121
+ from tan.core.system_manifest import find_manifest
122
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
123
+ from tan.exit_codes import ExitCode
124
+ from tan.output_format import FORMAT_HELP, OutputFormat
125
+
126
+ #: The orchestrator state cache removed alongside the build root -- verbatim
127
+ #: `alp_clean.py`'s `targets[1]`. The orchestrator actually writes its cache at
128
+ #: `<build_root>/.alp-build-state.json`, already subsumed by the recursive
129
+ #: build-root removal; this app-root path is the faithful, usually-absent target
130
+ #: the Python cleaner kept. Preserved, not "fixed" (`tan_core::clean` docs).
131
+ STATE_FILE = ".alp-build-state.json"
132
+
133
+ #: The manifest this command sweeps for out-of-tree slice build dirs, relative
134
+ #: to the resolved build root.
135
+ MANIFEST_NAME = "system-manifest.yaml"
136
+
137
+ #: The system-manifest schema major consumed here. A different value is a
138
+ #: warning and the manifest is IGNORED -- never read as if it were v1
139
+ #: (`SYSTEM_MANIFEST_SCHEMA_VERSION`).
140
+ MANIFEST_SCHEMA_VERSION = 1
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Pure path shape -- `tan_core::path_guard`
145
+ # ---------------------------------------------------------------------------
146
+
147
+
148
+ def _rust_join(base: str, rel: str) -> str:
149
+ """`PathBuf::push` semantics, which `os.path.join` does not have on Windows.
150
+
151
+ Rust replaces the base OUTRIGHT when the right-hand side carries its own
152
+ prefix -- a drive (`C:foo`), a UNC share (`\\\\server\\share\\x`) or the
153
+ device namespace (`\\\\?\\C:\\x`). `ntpath.join` agrees for a DIFFERENT
154
+ drive but treats a drive-relative path on the SAME drive as relative to the
155
+ accumulated path, so `join("C:/proj", "C:foo")` yields `C:/proj\\foo` where
156
+ Rust yields `C:foo`. `C:foo` is one of the shapes a `--build-root` guard has
157
+ to get right, so the divergence is closed here rather than tolerated.
158
+
159
+ Everything else defers to `os.path.join`, which already matches Rust for the
160
+ rooted-but-prefixless case (`\\rooted` keeps the base's drive) and for the
161
+ empty and `..` cases (verified against the Rust binary). On POSIX
162
+ `splitdrive` always reports no drive, so this is `os.path.join` verbatim.
163
+ """
164
+ if os.path.splitdrive(rel)[0]:
165
+ return rel
166
+ return os.path.join(base, rel)
167
+
168
+
169
+ def _cwd_or_dot() -> str:
170
+ try:
171
+ return os.getcwd()
172
+ except OSError:
173
+ return "."
174
+
175
+
176
+ def _normalize(path: str) -> str:
177
+ """Lexically collapse `.`/`..` without touching the filesystem --
178
+ `path_guard::normalize`.
179
+
180
+ `os.path.normpath`, so no symlink is resolved and a path that does not exist
181
+ still normalizes. Used for COMPARISON only; a reported path keeps whatever
182
+ spelling the oracle would report.
183
+
184
+ One known divergence, unreachable here: Rust's `normalize` pops past the
185
+ start, so a relative `..` collapses to the empty path where `normpath`
186
+ keeps `..`. Every input below is already absolute (the project root is
187
+ cwd-anchored, the build root is `<tree>/build` with the tree cwd-anchored, and
188
+ slice dirs are joined onto the clean root), so the
189
+ difference cannot be reached.
190
+ """
191
+ return os.path.normpath(path)
192
+
193
+
194
+ def _has_normal_component(normalized: str) -> bool:
195
+ """Whether the path names anything at all beyond a root/prefix -- Rust's
196
+ `components().any(Component::Normal)`.
197
+
198
+ False for `/`, `C:\\`, `C:` and a bare UNC share `\\\\server\\share`: each is
199
+ a root whose recursive removal takes out far more than a build tree.
200
+ """
201
+ return os.path.splitdrive(normalized)[1].strip("\\/") != ""
202
+
203
+
204
+ def _is_under(base: str, path: str) -> bool:
205
+ """Component-wise containment on normalized paths -- Rust's
206
+ `Path::starts_with`, NOT a string prefix test.
207
+
208
+ `/p/build` contains `/p/build/x` and itself, but NOT the sibling
209
+ `/p/build2` -- which a plain `str.startswith` would wrongly accept, and
210
+ which decides whether a slice dir is treated as a separate removal target.
211
+ Case-sensitive, matching Rust (which folds case on the Windows drive prefix
212
+ only, and both sides here come from the same join).
213
+ """
214
+ base_n, path_n = _normalize(base), _normalize(path)
215
+ if base_n == path_n:
216
+ return True
217
+ return path_n.startswith(base_n.rstrip("\\/") + os.sep)
218
+
219
+
220
+ def _resolves_onto_project_root(target: str, project_root: str) -> bool:
221
+ """The lexical ancestor test of [`is_unsafe_removal_target`], re-run on
222
+ RESOLVED copies -- an intentional divergence from the oracle.
223
+
224
+ `path_guard::is_unsafe_removal_target` (and this port before it) compares
225
+ `normpath` spellings only, so both of its arms are defeated by naming the
226
+ project root through a symlinked PARENT component: with
227
+ `<base>/wlink -> <base>/work`, `--build-root <base>/wlink/proj` is not
228
+ lexically `<base>/work/proj`, `_remove_dir` lstats only the final component
229
+ (a real directory, so the link arm never fires), and `shutil.rmtree` deletes
230
+ the user's sources at exit 0 with `issues: []`. Measured: the frozen oracle
231
+ destroys the identical tree, so this is an inherited design gap rather than
232
+ a port regression -- and a destructive one is worth diverging on. The test
233
+ is ADDITIVE, so it can only ever refuse more, never remove more.
234
+
235
+ A link spelled AS the target is deliberately NOT resolved: [`_remove_dir`]
236
+ unlinks such a target ITSELF and never recurses through it, so removing it
237
+ costs exactly the link (verified against the Rust binary). Resolving here
238
+ would refuse that harmless case.
239
+
240
+ Resolution is for the COMPARISON only. `data.buildRoot` and every reported
241
+ `targets[].path` keep the spelling the caller typed, which
242
+ `test_normalize_is_lexical_and_never_resolves_a_symlink` and the
243
+ `clean` oracle-parity fixture both pin.
244
+ """
245
+ if is_link(target):
246
+ return False
247
+ resolved = os.path.realpath(target)
248
+ if not _has_normal_component(_normalize(resolved)):
249
+ return True
250
+ return _is_under(resolved, os.path.realpath(project_root))
251
+
252
+
253
+ def is_unsafe_removal_target(project_root: str, target: str) -> bool:
254
+ """True when recursively removing `target` would take out far more than a
255
+ build tree, and the caller must refuse -- `path_guard::is_unsafe_removal_target`.
256
+
257
+ Rejects a filesystem/drive/UNC root (no `Normal` component at all), and the
258
+ project root itself or any ancestor of it -- named lexically OR through a
259
+ symlinked parent ([`_resolves_onto_project_root`]). Deliberately does NOT
260
+ require containment under the project root: an out-of-tree slice build dir
261
+ is a supported clean target (see the module docstring).
262
+ """
263
+ target_n = _normalize(target)
264
+ if not _has_normal_component(target_n):
265
+ return True
266
+ # True when the target IS the project root or an ancestor of it -- both
267
+ # would delete the user's sources.
268
+ if _is_under(target_n, project_root):
269
+ return True
270
+ return _resolves_onto_project_root(target, project_root)
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Pure removal planning -- `tan_core::clean`
275
+ # ---------------------------------------------------------------------------
276
+
277
+ #: Filesystem-kind + disposition pairs, keyed by what a probe found.
278
+ _DIR_ACTIONS = {False: ("dir", "removed"), True: ("dir", "would-remove")}
279
+ _FILE_ACTIONS = {False: ("file", "removed"), True: ("file", "would-remove")}
280
+
281
+
282
+ @dataclass(frozen=True)
283
+ class _Rejected:
284
+ """A candidate refused as too dangerous to remove, with where it came from
285
+ so the message can name the culprit -- `clean::RejectedTarget`."""
286
+
287
+ path: str
288
+ #: `build-root` | `slice` -- the state file is never screened (see
289
+ #: [`plan_clean_targets`]), so it has no rejection message.
290
+ origin: str
291
+ core_id: str = ""
292
+ raw: str = ""
293
+ #: The other project's root when a slice dir sits inside it (tan-cli#1516).
294
+ foreign_project: str = ""
295
+
296
+ def reason(self) -> str:
297
+ """One-line explanation naming the source, verbatim from
298
+ `RejectedTarget::reason` -- except the foreign-project branch, which is
299
+ tan's own (deliberate oracle divergence, tan-cli#1516: v0.4.1 removes
300
+ that dir). The em dash is the oracle's own character."""
301
+ if self.foreign_project:
302
+ return (
303
+ f"refusing to remove slice '{self.core_id}' build_dir "
304
+ f'"{self.raw}" (resolves to {self.path}) — it lies inside '
305
+ f"another project ({self.foreign_project} holds a board.yaml): "
306
+ "this project was copied or renamed after a build, or the dir "
307
+ "belongs to an enclosing project; rebuild this project or fix "
308
+ "build/system-manifest.yaml"
309
+ )
310
+ if self.origin == "slice":
311
+ return (
312
+ f"refusing to remove slice '{self.core_id}' build_dir "
313
+ f'"{self.raw}" (resolves to {self.path}) \u2014 it is the '
314
+ "project root, an ancestor of it, or a filesystem root; fix "
315
+ "build/system-manifest.yaml"
316
+ )
317
+ return (
318
+ f"refusing to remove build root {self.path} \u2014 it is the "
319
+ "project root, an ancestor of it, or a filesystem root"
320
+ )
321
+
322
+
323
+ @dataclass
324
+ class _Plan:
325
+ """`clean::CleanPlan`: paths cleared for removal (build root first), plus
326
+ everything refused. A non-empty `rejected` means the command must report and
327
+ fail -- never quietly clean less than asked."""
328
+
329
+ targets: list[str] = field(default_factory=list)
330
+ rejected: list[_Rejected] = field(default_factory=list)
331
+
332
+
333
+ def _subsumed_by_build_root(build_root: str, resolved: str) -> bool:
334
+ """Whether a slice `build_dir` is already covered by the recursive build-root
335
+ removal, so it contributes no extra target.
336
+
337
+ Two tests, and EITHER answering "inside" is enough:
338
+
339
+ * the oracle's lexical `clean::is_under`, which is what parity is measured
340
+ against; and
341
+ * [`confine_to_build_root`], the port's hardened containment guard, reused
342
+ here rather than re-derived -- this is the one question in `clean` whose
343
+ semantics really are "is this path confined under the build root". It
344
+ resolves both sides, so it also catches a junction or symlink inside
345
+ `build/` that points out of the tree, and the Windows shapes
346
+ (`C:foo`, `\\x`, UNC, `\\\\?\\`) a lexical test misses.
347
+
348
+ OR, not AND, deliberately: a disagreement can then only make the port treat
349
+ a path as ALREADY COVERED, i.e. remove strictly less than the oracle -- and
350
+ the disagreement only arises when the path genuinely does live inside
351
+ `build_root`, which the recursive removal handles anyway, so the resulting
352
+ disk state is identical. Requiring both to agree would let a resolved-inside
353
+ path become a SEPARATE `shutil.rmtree` call, which is the one direction a
354
+ destructive command must not drift in.
355
+ """
356
+ if _is_under(build_root, resolved):
357
+ return True
358
+ if not os.path.isabs(resolved):
359
+ # A DRIVE-RELATIVE leftover (`C:rel`, the one shape `_rust_join` cannot
360
+ # make absolute because Rust does not either). It cannot be containment-
361
+ # tested against an unrelated base without inventing a meaning for it:
362
+ # `Path("C:/proj/build") / "C:rel"` re-reads it as relative to the base
363
+ # and answers "inside", while Rust reports it as its own target resolved
364
+ # against drive C:'s current directory. Measured -- a manifest
365
+ # `build_dir: "C:rel"` made the oracle list an `absent` target the port
366
+ # silently dropped. The lexical answer above IS the oracle's answer here,
367
+ # and the candidate is still screened by `is_unsafe_removal_target`.
368
+ return False
369
+ try:
370
+ confine_to_build_root(Path(build_root), resolved)
371
+ except (MaterialiseError, OSError, ValueError):
372
+ # `MaterialiseError` is the escape verdict; `OSError`/`ValueError` come
373
+ # from `Path.resolve()` on a shape the host rejects outright (a device-
374
+ # namespace path, an over-long name). Either way: not proven inside.
375
+ return False
376
+ return True
377
+
378
+
379
+ def _foreign_project_root(path: str, own_roots: tuple[str, ...]) -> str | None:
380
+ """The nearest ancestor of an out-of-tree slice dir that holds a
381
+ `board.yaml`, when that ancestor is NOT this project -- tan-cli#1516.
382
+
383
+ `tan build` records each slice `build_dir` as an absolute path, so a project
384
+ copied or renamed after a build carries a manifest naming the ORIGINAL
385
+ project's build tree. Out-of-tree slice dirs stay supported (a Yocto tmp dir
386
+ sits under no project at all); one inside someone else's project tree never
387
+ is. The walk stops at this project's own roots (the project root, the
388
+ `--build-root` tree, the `--board-yaml` directory), so a slice dir elsewhere
389
+ in this project is never misread as foreign.
390
+
391
+ Two walks, and EITHER finding a foreign root refuses: the lexical spelling
392
+ (what the message names) and the RESOLVED one, because a symlinked component
393
+ or a `..` through a linked project root reaches a different directory than
394
+ its spelling suggests -- the same reason [`_resolves_onto_project_root`]
395
+ exists. Own roots are matched by identity (`samefile`), not spelling, so a
396
+ case-variant `--project` on a case-insensitive filesystem stays "own".
397
+ """
398
+ # `abspath` only for the walk: it anchors a drive-relative `C:rel` the way
399
+ # Windows will when the removal runs. The reported path is unchanged.
400
+ starts = (os.path.dirname(_normalize(os.path.abspath(path))),
401
+ os.path.dirname(os.path.realpath(os.path.abspath(path))))
402
+ own_names = {_normalize(r) for r in own_roots} | {os.path.realpath(r) for r in own_roots}
403
+ existing = [r for r in own_roots if os.path.exists(r)]
404
+
405
+ def _is_own(directory: str) -> bool:
406
+ if directory in own_names or os.path.realpath(directory) in own_names:
407
+ return True
408
+ try:
409
+ return any(os.path.samefile(directory, r) for r in existing)
410
+ except OSError:
411
+ return False
412
+
413
+ for current in starts:
414
+ while not _is_own(current):
415
+ if os.path.isfile(os.path.join(current, "board.yaml")):
416
+ return current
417
+ parent = os.path.dirname(current)
418
+ if parent == current:
419
+ break
420
+ current = parent
421
+ return None
422
+
423
+
424
+ def plan_clean_targets(
425
+ clean_root: str,
426
+ build_root: str,
427
+ slices: list[dict[str, Any]],
428
+ project_root: str | None = None,
429
+ board_yaml_dir: str | None = None,
430
+ ) -> _Plan:
431
+ """Ordered, de-duplicated removal targets -- `clean::clean_targets`.
432
+
433
+ 1. `build_root`, recursively.
434
+ 2. `<project_root>/.alp-build-state.json`.
435
+ 3. each slice `build_dir` that lies OUTSIDE `build_root` (see
436
+ [`_subsumed_by_build_root`]); a relative value resolves against
437
+ `project_root`, an absolute one is taken as-is.
438
+
439
+ Every candidate except the state file is then screened by
440
+ [`is_unsafe_removal_target`]. The manifest is unvalidated file content and
441
+ `build_dir: ""`, `.`, `/` or `../..` each resolve to the project root or
442
+ above; a rejected candidate goes to `rejected` so the caller can surface it
443
+ and fail, never silently dropped. The state file is exempt because it is a
444
+ single unlink of one fixed name under the project root, never a recursive
445
+ removal -- matching the oracle's own exemption.
446
+ """
447
+ # `clean_root` is the tree a relative slice `build_dir` resolves against (the
448
+ # `--build-root` tree X, or the project root without the flag);
449
+ # `project_root` is the real project root and is screened separately, so a
450
+ # manifest slice dir that resolves onto it is refused even when it sits
451
+ # inside X.
452
+ if project_root is None:
453
+ project_root = clean_root
454
+ # Directories that are THIS project for the foreign-project screen
455
+ # (tan-cli#1516): a `--board-yaml cfg/board.yaml` project's own `cfg/`
456
+ # must not read as another project.
457
+ own_roots = (project_root, clean_root) + ((board_yaml_dir,) if board_yaml_dir else ())
458
+
459
+ def _unsafe(path: str) -> bool:
460
+ return is_unsafe_removal_target(project_root, path) or is_unsafe_removal_target(
461
+ clean_root, path
462
+ )
463
+
464
+ candidates: list[tuple[str, _Rejected | None]] = [
465
+ (build_root, _Rejected(build_root, "build-root")),
466
+ (_rust_join(clean_root, STATE_FILE), None),
467
+ ]
468
+ for entry in slices:
469
+ raw = entry.get("build_dir")
470
+ if not isinstance(raw, str):
471
+ continue
472
+ resolved = _rust_join(clean_root, raw)
473
+ if not _subsumed_by_build_root(build_root, resolved):
474
+ core_id = entry.get("core_id", "")
475
+ foreign = _foreign_project_root(resolved, own_roots) or ""
476
+ candidates.append(
477
+ # `str()`: a plain-scalar `core_id: 7` is a valid String to
478
+ # serde_yaml, so it can reach the rejection message as an int.
479
+ (resolved, _Rejected(resolved, "slice", str(core_id), raw, foreign))
480
+ )
481
+
482
+ plan = _Plan()
483
+ seen: list[str] = []
484
+ for path, rejection in candidates:
485
+ key = _normalize(path)
486
+ if key in seen:
487
+ continue
488
+ seen.append(key)
489
+ if rejection is None or not (
490
+ _unsafe(path)
491
+ or rejection.foreign_project
492
+ or (
493
+ rejection.origin == "slice"
494
+ and not is_link(path)
495
+ and os.path.isfile(os.path.join(path, "board.yaml"))
496
+ )
497
+ ):
498
+ plan.targets.append(path)
499
+ else:
500
+ plan.rejected.append(rejection)
501
+ return plan
502
+
503
+
504
+ def _classify(is_dir: bool, is_file: bool, dry_run: bool) -> tuple[str, str]:
505
+ """`(kind, action)` from a probed filesystem type + the dry-run flag --
506
+ `clean::classify`. A path that is neither a dir nor a file is `absent`:
507
+ skipped entirely, not counted, no removal attempted."""
508
+ if is_dir:
509
+ return _DIR_ACTIONS[dry_run]
510
+ if is_file:
511
+ return _FILE_ACTIONS[dry_run]
512
+ return ("absent", "absent")
513
+
514
+
515
+ # ---------------------------------------------------------------------------
516
+ # The system-manifest sweep
517
+ # ---------------------------------------------------------------------------
518
+
519
+
520
+ #: `serde` renders an unexpected value as `<kind>` for containers and
521
+ #: <code>&lt;kind&gt; `value`</code> for scalars. Harvested from the Rust binary
522
+ #: across 25 malformed-manifest shapes, so the port's warning text matches
523
+ #: rather than approximates.
524
+ _SERDE_KIND = {
525
+ type(None): "unit value",
526
+ bool: "boolean",
527
+ int: "integer",
528
+ float: "floating point",
529
+ str: "string",
530
+ list: "sequence",
531
+ dict: "map",
532
+ }
533
+
534
+
535
+ def _serde_value(value: Any) -> str:
536
+ """How serde names an unexpected value in `invalid type: ...`.
537
+
538
+ `sequence`/`map`/`unit value` carry no payload; a bool renders lowercase
539
+ (`true`), a string in double quotes, a number in backticks.
540
+ """
541
+ kind = _SERDE_KIND.get(type(value), type(value).__name__)
542
+ if value is None or isinstance(value, (list, dict)):
543
+ return kind
544
+ if isinstance(value, bool):
545
+ return f"{kind} `{'true' if value else 'false'}`"
546
+ if isinstance(value, str):
547
+ return f'{kind} "{value}"'
548
+ return f"{kind} `{value}`"
549
+
550
+
551
+ def _is_yaml_scalar(value: Any) -> bool:
552
+ """Whether serde_yaml would accept this value for a `String` field.
553
+
554
+ YAML plain scalars are untyped, and serde_yaml 0.9 hands one to whichever
555
+ visitor the target field asks for -- so `core_id: 7` deserializes into
556
+ `String` as `"7"` with no error. Verified against the Rust binary
557
+ (`core_id: 7` parses clean and the run exits 0 with no issue). A port that
558
+ demanded `isinstance(str)` here would emit a `clean.manifest-unreadable`
559
+ warning the oracle does not.
560
+ """
561
+ return isinstance(value, (str, int, float, bool))
562
+
563
+
564
+ def parse_manifest_slices(text: str) -> tuple[list[dict[str, Any]], str | None]:
565
+ """`(slices, error)` for a `system-manifest.yaml` document --
566
+ `parse_system_manifest`, narrowed to the one field this command consumes.
567
+
568
+ FAIL-CLOSED, matching serde: a document that is not a well-formed v1
569
+ manifest yields NO slices and an error string, so a half-read manifest can
570
+ never hand a garbage `build_dir` to a recursive removal. Tolerant of
571
+ additive v1 fields (`deny_unknown_fields` is deliberately off upstream).
572
+
573
+ Message text was matched against the Rust binary shape by shape; two
574
+ divergences remain and are deliberate:
575
+
576
+ * the trailing ` at line N column M` serde_yaml appends is absent --
577
+ `yaml.safe_load` discards node marks, and recovering them would mean a
578
+ custom composer for a warning string;
579
+ * a raw YAML SYNTAX error (a stray tab, an unclosed flow node) carries
580
+ PyYAML's wording after the shared `system-manifest is not valid YAML: `
581
+ prefix.
582
+
583
+ `build_dir` REQUIRES a real string, where serde_yaml would coerce a plain
584
+ scalar (`build_dir: 7` becomes `"7"` and, verified against the Rust binary,
585
+ a THIRD removal target at `<project>/7`). Diverging here is deliberate: this
586
+ is the only manifest field that becomes a path handed to a recursive
587
+ removal, and deriving a delete target from a number in a malformed manifest
588
+ is not a behaviour worth reproducing. The port removes strictly less, the
589
+ SDK emits strings, and [`test_numeric_build_dir_is_not_turned_into_a_delete_target`]
590
+ pins it so the choice cannot drift silently.
591
+
592
+ PyYAML IS a declared base dependency (`pyproject.toml`
593
+ `[project].dependencies`), so this arm is the `--no-deps`/broken-venv case
594
+ rather than the normal one -- an earlier version of this docstring claimed
595
+ tan declared no YAML dependency at all (tan-cli#574). The arm stays because
596
+ that case is still reachable, and its absence is REPORTED rather than
597
+ swallowed: a project whose slices build out of tree would otherwise have
598
+ them left behind with no indication why. Still only a warning; `clean`
599
+ never fails over a manifest.
600
+ """
601
+ try:
602
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
603
+ except ImportError: # pragma: no cover -- present in every real workspace
604
+ return [], (
605
+ "no YAML parser available (PyYAML is not installed), so out-of-tree "
606
+ "slice build dirs were not swept"
607
+ )
608
+ try:
609
+ doc = yaml.safe_load(text)
610
+ except Exception as err: # noqa: BLE001 -- any parser failure, incl. the C ext
611
+ return [], f"system-manifest is not valid YAML: {err}"
612
+
613
+ prefix = "system-manifest is not valid YAML: "
614
+ if doc is None:
615
+ # `yaml.safe_load` collapses two cases serde keeps apart: a file with no
616
+ # document at all (empty, whitespace-only, comments-only, or a bare
617
+ # `---`) has no fields, so serde reports the first REQUIRED one; an
618
+ # explicit `null`/`~` node is a real value of the wrong TYPE. Both were
619
+ # measured against the Rust binary. Told apart by whether the source
620
+ # carries any node text of its own.
621
+ body = "\n".join(
622
+ line
623
+ for line in text.splitlines()
624
+ if line.strip() not in ("", "---", "...") and not line.lstrip().startswith("#")
625
+ )
626
+ if not body.strip():
627
+ return [], f"{prefix}missing field `schema_version`"
628
+ return [], f"{prefix}invalid type: unit value, expected struct SystemManifest"
629
+ if not isinstance(doc, dict):
630
+ return [], f"{prefix}invalid type: {_serde_value(doc)}, expected struct SystemManifest"
631
+
632
+ if "schema_version" not in doc:
633
+ return [], f"{prefix}missing field `schema_version`"
634
+ version = doc["schema_version"]
635
+ # `bool` is an `int` in Python but never a serde `u32`, so it is a type
636
+ # error, not a version. Checked before the int test for that reason.
637
+ if isinstance(version, bool) or not isinstance(version, int):
638
+ return [], (
639
+ f"{prefix}schema_version: invalid type: {_serde_value(version)}, expected u32"
640
+ )
641
+ if version != MANIFEST_SCHEMA_VERSION:
642
+ return [], (
643
+ f"unsupported system-manifest schema_version {version} (this CLI "
644
+ f"consumes v{MANIFEST_SCHEMA_VERSION}); upgrade the CLI or the SDK "
645
+ "so the versions match"
646
+ )
647
+
648
+ raw = doc.get("slices", [])
649
+ if not isinstance(raw, list):
650
+ return [], f"{prefix}slices: invalid type: {_serde_value(raw)}, expected a sequence"
651
+ for index, entry in enumerate(raw):
652
+ # `core_id`/`os` are non-Option in the Rust `Slice`, so serde fails the
653
+ # WHOLE document when either is missing; `build_dir` is
654
+ # `Option<String>`, so `null`/absent is fine and a sequence is not. A
655
+ # partial read here would act on a manifest the oracle rejects outright.
656
+ if not isinstance(entry, dict):
657
+ return [], (
658
+ f"{prefix}slices[{index}]: invalid type: {_serde_value(entry)}, "
659
+ "expected struct Slice"
660
+ )
661
+ for required in ("core_id", "os"):
662
+ if not _is_yaml_scalar(entry.get(required)):
663
+ missing = required not in entry or entry.get(required) is None
664
+ return [], (
665
+ f"{prefix}slices[{index}]: missing field `{required}`"
666
+ if missing
667
+ else f"{prefix}slices[{index}].{required}: invalid type: "
668
+ f"{_serde_value(entry[required])}, expected a string"
669
+ )
670
+ build_dir = entry.get("build_dir")
671
+ if build_dir is not None and not _is_yaml_scalar(build_dir):
672
+ return [], (
673
+ f"{prefix}slices[{index}].build_dir: invalid type: "
674
+ f"{_serde_value(build_dir)}, expected a string"
675
+ )
676
+ return list(raw), None
677
+
678
+
679
+ def _read_manifest(build_root: str) -> tuple[list[dict[str, Any]], str | None]:
680
+ """The manifest's slices, or `([], error)`.
681
+
682
+ A READ failure -- absent, a directory in its place, a denied ACL, non-UTF-8
683
+ bytes -- is SILENT (`([], None)`), matching the oracle's `Err(_) => None`
684
+ arm: no issue, no text, exit unchanged. Verified against the Rust binary for
685
+ both the directory and the non-UTF-8 cases. Only a document that WAS read
686
+ and could not be understood is a warning.
687
+ """
688
+ # tan-cli#1482: the nested `<X>/build/system-manifest.yaml` spelling is
689
+ # found the way size/image/flash find it.
690
+ try:
691
+ text = Path(find_manifest(build_root)).read_text(encoding="utf-8")
692
+ except (OSError, UnicodeDecodeError, ValueError):
693
+ return [], None
694
+ return parse_manifest_slices(text)
695
+
696
+
697
+ # ---------------------------------------------------------------------------
698
+ # Removal
699
+ # ---------------------------------------------------------------------------
700
+
701
+ # `is_link`/`os_error_text`/`_reraise_removal_failure`/
702
+ # `_retry_after_clearing_readonly`/`_RMTREE_HOOK`/`remove_dir` MOVED to
703
+ # `tan.core.dir_removal` (tan-cli#790, imported at the top of this file):
704
+ # `tan sdk remove` needs the identical read-only-retry/junction-safe removal
705
+ # `tan clean` already carries, and `tan.core` may import no `tan.commands.*`
706
+ # module, so the shared primitives had to live below both rather than inside
707
+ # either. Re-imported under their ORIGINAL names (`_remove_dir` is
708
+ # `remove_dir` aliased), so every internal call site below is unchanged and
709
+ # so the existing test suite's `monkeypatch.setattr(clean_cmd, "_remove_dir",
710
+ # ...)` and `from tan.commands.clean_cmd import is_link, os_error_text,
711
+ # _retry_after_clearing_readonly` keep resolving exactly as before -- a
712
+ # monkeypatch only cares which module ATTRIBUTE it overwrites, never which
713
+ # module originally defined the value that attribute pointed at, and an
714
+ # `import` binds a module-level attribute the same way a `def` would.
715
+
716
+
717
+ # ---------------------------------------------------------------------------
718
+ # SDK resolution
719
+ # ---------------------------------------------------------------------------
720
+
721
+
722
+ def _cli_workspace_root(project_arg: str | None) -> Path:
723
+ """`util::cli_workspace_root`: `--project` joined to the cwd, UNNORMALIZED
724
+ (the oracle normalizes only for the reported `project.root`), or the cwd
725
+ itself when the flag is absent. Feeds the SDK guard's discovery walk, whose
726
+ sibling/ancestor probes are lexical -- so the unnormalized spelling is what
727
+ keeps the two implementations probing the same directories."""
728
+ try:
729
+ cwd = os.getcwd()
730
+ except OSError:
731
+ cwd = "." # `current_dir().unwrap_or_else(|_| PathBuf::from("."))`
732
+ return Path(cwd if project_arg is None else _rust_join(cwd, project_arg))
733
+
734
+
735
+ def _sdk_root_refusal(sdk_root_arg: str | None, workspace_root: Path) -> str:
736
+ """`clean.sdk-root-not-found`'s message (tan-cli#1444): where tan looked
737
+ and the flag that fixes it, the same shape as `flash.sdk-root-not-found`
738
+ (#1423). `--sdk-root` is terminal (I-31): no other tier was tried, so that
739
+ branch does not claim the ladder was searched."""
740
+ if sdk_root_arg is not None:
741
+ return (
742
+ f"Cannot locate alp-sdk root. `--sdk-root {sdk_root_arg}` is not an "
743
+ "alp-sdk checkout (no `scripts/alp_project.py` under it)."
744
+ )
745
+ return (
746
+ f"Cannot locate alp-sdk root. {sdk_search_summary(workspace_root)} "
747
+ f"To fix it, {NO_SDK_NEXT_STEPS}."
748
+ )
749
+
750
+
751
+ def sdk_root_resolves(sdk_root: str | None, workspace_root: Path) -> bool:
752
+ """Whether `tan.core.sdk_discovery.resolve_sdk_root_ladder` would resolve a checkout --
753
+ the guard behind `clean.sdk-root-not-found`.
754
+
755
+ `--sdk-root` is TERMINAL (I-31): an explicit path without the loader marker
756
+ fails here rather than falling through to a lower tier and cleaning against
757
+ a checkout the caller never named -- checked explicitly below because the
758
+ ladder itself returns a `--sdk-root` value unvalidated (matching the
759
+ oracle's `resolve_sdk_tiered`, terminal for REPORTING); this gate matches
760
+ `util::resolve_sdk_root`, terminal AND validated. The project-pin and
761
+ global-default tiers are best-effort, so a stale pointer falls through
762
+ instead of locking the user out.
763
+
764
+ The ladder's LAST tier is the wide positional walk (root, child `alp-sdk`,
765
+ sibling `alp-sdk`, sibling `alp-sdk-upstream`, then ancestors; first match
766
+ wins), but it is reached only when the narrower `resolve_sdk_tiered`
767
+ discovery tier AHEAD of it answers `None` -- a narrow hit short-circuits.
768
+ So in a workspace holding BOTH a child `alp-sdk` and a lateral one, what
769
+ gates this command is the lateral checkout, not the child, and the wide
770
+ walk never runs (measured against the oracle: `tan clean` there resolves
771
+ `../alp-sdk` too, tan-cli#263).
772
+
773
+ That ordering does not move this boolean: every candidate the narrow tier
774
+ probes is also one the wide walk probes, so a narrow hit implies a wide
775
+ hit. What the wide tail still buys is the case the narrow tier cannot
776
+ answer -- a `tan bootstrap` workspace whose checkout is a CHILD of the cwd,
777
+ where narrow returns `None` and, without the tail, `tan clean` would refuse
778
+ to run (tan-cli#218; measured: the oracle resolves `<ws>/alp-sdk` there).
779
+
780
+ Note this gate and the REPORTED `sdk` key are still two different
781
+ resolutions: [`resolve_sdk`][tan.commands.presets_cmd.resolve_sdk] (below)
782
+ reports through `resolve_sdk_tiered` alone, so a bootstrap-child workspace
783
+ gates open here while reporting no `sdk` at all.
784
+ """
785
+ resolution = resolve_sdk_root_ladder(sdk_root, workspace_root)
786
+ if resolution.path is None:
787
+ return False
788
+ if resolution.tier == "sdkRootFlag":
789
+ return resolution.path.joinpath(*SDK_MARKER).exists()
790
+ return True
791
+
792
+
793
+ # ---------------------------------------------------------------------------
794
+ # Envelope assembly
795
+ # ---------------------------------------------------------------------------
796
+
797
+
798
+ @dataclass
799
+ class _Outcome:
800
+ """What one run produced. Built and returned, never emitted in place, so the
801
+ exception guard in [`clean`] can wrap the whole computation without also
802
+ catching `typer.Exit` (a `RuntimeError` subclass, not a `SystemExit`, which a
803
+ bare `except Exception` would otherwise swallow)."""
804
+
805
+ exit_code: ExitCode
806
+ data: dict[str, Any]
807
+ project: Project
808
+ sdk: SdkInfo | None
809
+ issues: list[Issue]
810
+ text: list[str]
811
+
812
+
813
+ def _report(build_root: str, dry_run: bool, targets: list[dict[str, str]], removed: int):
814
+ return {
815
+ "buildRoot": build_root,
816
+ "dryRun": dry_run,
817
+ "targets": targets,
818
+ "removed": removed,
819
+ }
820
+
821
+
822
+ def _run(
823
+ *,
824
+ app_path: str,
825
+ build_root_arg: str | None,
826
+ dry_run: bool,
827
+ project_arg: str | None,
828
+ board_yaml_arg: str | None,
829
+ sdk_root_arg: str | None,
830
+ quiet: bool,
831
+ ) -> _Outcome:
832
+ """The whole command as a computation returning one outcome. Nothing here
833
+ emits or exits; [`clean`] does both exactly once."""
834
+ workspace_root, board_yaml = resolve_project_paths(project_arg, board_yaml_arg)
835
+ # tan-cli#236: `boardYaml` reported only when the file really exists.
836
+ project = Project.resolved(workspace_root, board_yaml)
837
+ # tan-cli#468: `resolve_sdk` now always returns an `ActiveSdk` -- never a
838
+ # bare `None` -- so `broken_project_pin`/`foreign_global_default_for` are
839
+ # read off it unconditionally, whether or not `.path` resolved. `sdk` (the
840
+ # envelope's `SdkInfo`) is the one field that still depends on `.path`: no
841
+ # checkout means no `sdk` block, exactly as before.
842
+ resolved_sdk = resolve_sdk(sdk_root_arg, workspace_root)
843
+ sdk = (
844
+ SdkInfo.from_resolution(resolved_sdk.path, resolved_sdk)
845
+ if resolved_sdk.path is not None
846
+ else None
847
+ )
848
+ pin_issue = project_pin_issue(resolved_sdk.broken_project_pin, resolved_sdk.tier)
849
+ foreign_issue = global_default_foreign_project_issue(
850
+ resolved_sdk.foreign_global_default_for
851
+ )
852
+ resolution_issues = [i for i in (pin_issue, foreign_issue) if i is not None]
853
+
854
+ # App base: a non-`.` positional roots the removal at that app dir,
855
+ # overriding `--project`; `.` falls back to the resolved workspace.
856
+ if app_path == ".":
857
+ project_root = workspace_root
858
+ else:
859
+ try:
860
+ cwd = os.getcwd()
861
+ except OSError:
862
+ cwd = "."
863
+ project_root = _rust_join(cwd, app_path)
864
+
865
+ # SDK-root guard -- faithful to `alp_clean.py`'s `log.die('Cannot locate
866
+ # alp-sdk root.')`. Arguably YAGNI (removing a build dir needs no SDK), but
867
+ # the oracle keeps it, so the port keeps it. tan-cli#1444: the message
868
+ # names where tan looked and the flag that fixes it, as flash's does
869
+ # (#1423); `test_clean_parity.json` is history only, nothing replays it.
870
+ guard_root = _cli_workspace_root(project_arg)
871
+ if not sdk_root_resolves(sdk_root_arg, guard_root):
872
+ message = _sdk_root_refusal(sdk_root_arg, guard_root)
873
+ return _Outcome(
874
+ exit_code=ExitCode.RUNTIME_FAILURE,
875
+ data=_report("", dry_run, [], 0),
876
+ project=project,
877
+ sdk=sdk,
878
+ # tan-cli#468: a broken `.alp/sdk-path` pin (or a foreign
879
+ # `globalDefault`) with NO other tier resolving anything used to
880
+ # report this refusal alone -- `resolved_sdk` was a bare `None`
881
+ # here, dropping both facts on the floor.
882
+ issues=[*resolution_issues, Issue("clean.sdk-root-not-found", "error", message)],
883
+ text=[f"clean: {message}"],
884
+ )
885
+
886
+ # DELIBERATE oracle divergence (v0.4.1 removes X itself): do not revert in a
887
+ # parity sweep. `--build-root X` means what it means to `tan build` / `size` / `image` /
888
+ # `flash` (tan-cli#1405/#1411, #1482): X is the PROJECT TREE the build ran
889
+ # under (absolute as-is, relative against the CURRENT DIRECTORY), and the
890
+ # build output lives in `<X>/build`, which is what clean removes. Passing the
891
+ # same X to `build` and `clean` therefore never touches X's sources. Without
892
+ # the flag it is `<project_root>/build`. The default is deliberately NOT
893
+ # normalized -- `data.buildRoot` is a compared field.
894
+ clean_root = project_root
895
+ refusal: str | None = None
896
+ build_dir_why: str | None = None
897
+ if build_root_arg is not None:
898
+ if build_root_arg == "":
899
+ # `rm -rf $UNSET_VAR` shape: an empty value is never a tree name.
900
+ tree = _normalize(project_root)
901
+ build_root = tree
902
+ refusal = "an empty --build-root"
903
+ else:
904
+ tree = _normalize(
905
+ build_root_arg if os.path.isabs(build_root_arg)
906
+ else _rust_join(_cwd_or_dot(), build_root_arg)
907
+ )
908
+ clean_root = tree
909
+ build_root = _rust_join(tree, "build")
910
+ if not _has_normal_component(tree):
911
+ refusal = f"`{tree}` is a filesystem root"
912
+ elif os.path.isfile(_rust_join(tree, "system-manifest.yaml")) and not os.path.isfile(
913
+ _rust_join(build_root, "system-manifest.yaml")
914
+ ):
915
+ # tan-cli#1521: `size`/`image`/`flash` also accept X as the build
916
+ # directory itself, but clean appends `/build` to X, so that
917
+ # spelling would target `X/build`, find nothing and exit 0 with
918
+ # the real build tree still in place. Refuse loudly instead.
919
+ # Own text, not the shared `refusing to remove <tree>/build`
920
+ # prefix: that path does not exist, X is the build dir.
921
+ build_dir_why = (
922
+ f"`{tree}` is itself a build directory (it holds system-manifest.yaml); "
923
+ "pass the project tree it was built under, not its `build` directory"
924
+ )
925
+ refusal = build_dir_why
926
+ else:
927
+ build_root = _rust_join(project_root, "build")
928
+
929
+ # Fail fast, BEFORE the manifest is read: the target may not be the project
930
+ # root, an ancestor of it, a filesystem root, or a directory that is itself a
931
+ # project tree (holds a board.yaml) -- the `rm -rf` shapes that reach a
932
+ # recursive removal at exit 0.
933
+ if refusal is None and (
934
+ is_unsafe_removal_target(project_root, build_root)
935
+ or is_unsafe_removal_target(clean_root, build_root)
936
+ ):
937
+ refusal = "a build root may not be the project root, an ancestor of it, or a filesystem root"
938
+ if (
939
+ refusal is None
940
+ and not is_link(build_root)
941
+ and os.path.isfile(os.path.join(build_root, "board.yaml"))
942
+ ):
943
+ refusal = "it holds a board.yaml, so it is a project tree, not a build directory"
944
+ if refusal is not None:
945
+ why = (
946
+ build_dir_why
947
+ if refusal is build_dir_why
948
+ else f"refusing to remove `{build_root}`: {refusal}"
949
+ )
950
+ return _Outcome(
951
+ exit_code=ExitCode.RUNTIME_FAILURE,
952
+ data=_report(build_root, dry_run, [], 0),
953
+ project=project,
954
+ sdk=sdk,
955
+ # tan-cli#468: same reasoning as the guard above -- this refusal
956
+ # can fire even when `resolved_sdk.path` is `None`.
957
+ issues=[*resolution_issues, Issue("clean.unsafe-build-root", "error", why)],
958
+ text=[f"clean: {why}"],
959
+ )
960
+
961
+ text: list[str] = []
962
+ # tan-cli#263 review / tan-cli#464: `pin_issue`/`foreign_issue` computed
963
+ # once, above, from the one `resolved_sdk` -- this is the SAME pair the
964
+ # two refusals above prepend, read back rather than recomputed so all
965
+ # three paths can never disagree about whether either warning applies.
966
+ issues: list[Issue] = list(resolution_issues)
967
+
968
+ # Best-effort, manifest-aware sweep. Absence (or an unreadable file) is
969
+ # silent; a parse/version error is a warning, NEVER fatal -- clean must not
970
+ # fail over a manifest it only consults for an optimisation.
971
+ slices, manifest_error = _read_manifest(build_root)
972
+ if manifest_error is not None:
973
+ detail = f"ignoring unreadable system-manifest.yaml: {manifest_error}"
974
+ if not quiet:
975
+ text.append(f"clean: {detail}")
976
+ issues.append(Issue("clean.manifest-unreadable", "warning", detail))
977
+
978
+ plan = plan_clean_targets(
979
+ clean_root, build_root, slices, project_root, os.path.dirname(board_yaml)
980
+ )
981
+
982
+ records: list[dict[str, str]] = []
983
+ removed = 0
984
+ exit_code = ExitCode.SUCCESS
985
+
986
+ # A manifest naming a catastrophic build_dir is a broken manifest, not a
987
+ # reason to quietly clean less than asked. Report every refusal and fail.
988
+ for refused in plan.rejected:
989
+ why = refused.reason()
990
+ text.append(f"clean: {why}")
991
+ issues.append(Issue("clean.unsafe-target", "error", why))
992
+ records.append(
993
+ {"path": refused.path, "kind": "dir", "action": "refused-unsafe"}
994
+ )
995
+ exit_code = ExitCode.RUNTIME_FAILURE
996
+
997
+ for target in plan.targets:
998
+ probe = Path(target)
999
+ try:
1000
+ is_dir, is_file = probe.is_dir(), probe.is_file()
1001
+ except OSError:
1002
+ # `Path.is_dir()` swallows its own OSError, but a shape the host
1003
+ # rejects outright (an over-long name, an illegal character) can
1004
+ # still raise ValueError/OSError on some hosts. Treat as absent --
1005
+ # a path that cannot be probed is certainly not removed.
1006
+ is_dir = is_file = False
1007
+ except ValueError:
1008
+ is_dir = is_file = False
1009
+ kind, action = _classify(is_dir, is_file, dry_run)
1010
+
1011
+ if action == "would-remove":
1012
+ verb = "rmtree" if kind == "dir" else "unlink"
1013
+ text.append(f"[DRY] would {verb} {target}")
1014
+ records.append({"path": target, "kind": kind, "action": action})
1015
+ continue
1016
+ if action == "absent":
1017
+ records.append({"path": target, "kind": kind, "action": action})
1018
+ continue
1019
+
1020
+ text.append(f"clean: removing {target}")
1021
+ if kind == "dir":
1022
+ # Best-effort, matching `rmtree(ignore_errors=True)`: a failure does
1023
+ # NOT fail the command, but it IS reported -- as a warning issue and
1024
+ # the `remove-failed` action -- and is not counted `removed`. The
1025
+ # envelope must never claim a directory was removed when it was not.
1026
+ try:
1027
+ _remove_dir(target)
1028
+ except (OSError, ValueError) as err:
1029
+ detail = f"could not fully remove {target}: {os_error_text(err)}"
1030
+ text.append(f"clean: warning: {detail}")
1031
+ issues.append(Issue("clean.remove-failed", "warning", detail))
1032
+ records.append({"path": target, "kind": "dir", "action": "remove-failed"})
1033
+ else:
1034
+ removed += 1
1035
+ records.append({"path": target, "kind": "dir", "action": "removed"})
1036
+ else:
1037
+ # The state-file unlink is NOT ignore_errors in the Python source --
1038
+ # a failure propagates to exit 1.
1039
+ try:
1040
+ os.remove(target)
1041
+ except (OSError, ValueError) as err:
1042
+ detail = f"failed to remove {target}: {os_error_text(err)}"
1043
+ issues.append(Issue("clean.remove-failed", "error", detail))
1044
+ text.append(f"clean: error: {detail}")
1045
+ exit_code = ExitCode.RUNTIME_FAILURE
1046
+ records.append({"path": target, "kind": "file", "action": "remove-failed"})
1047
+ else:
1048
+ removed += 1
1049
+ records.append({"path": target, "kind": "file", "action": "removed"})
1050
+
1051
+ # Faithful trailing line -- suppressed under `--dry-run` (the Python source
1052
+ # guards it with `and not args.dry_run`) and when a hard removal error
1053
+ # already fired.
1054
+ if removed == 0 and not dry_run and exit_code == ExitCode.SUCCESS:
1055
+ text.append("clean: nothing to remove")
1056
+
1057
+ return _Outcome(
1058
+ exit_code=exit_code,
1059
+ data=_report(build_root, dry_run, records, removed),
1060
+ project=project,
1061
+ sdk=sdk,
1062
+ issues=issues,
1063
+ text=text,
1064
+ )
1065
+
1066
+
1067
+ def clean(
1068
+ app_path: str = typer.Argument(
1069
+ ".",
1070
+ metavar="APP_PATH",
1071
+ help=(
1072
+ "Application source directory (default: '.'). The build root "
1073
+ "defaults to <APP_PATH>/build; a non-'.' value overrides --project."
1074
+ ),
1075
+ ),
1076
+ build_root: str = typer.Option(
1077
+ None,
1078
+ "--build-root",
1079
+ metavar="PATH",
1080
+ help=(
1081
+ "Project tree to clean, the same meaning as `tan build --build-root`: "
1082
+ "removes <PATH>/build (default tree: <APP_PATH>). Relative to the "
1083
+ "current directory. Refused if the target holds a board.yaml."
1084
+ ),
1085
+ ),
1086
+ dry_run: bool = typer.Option(
1087
+ False, "--dry-run", help="List the paths that would be removed; delete nothing."
1088
+ ),
1089
+ project: str = typer.Option(
1090
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1091
+ ),
1092
+ board_yaml: str = typer.Option(
1093
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
1094
+ ),
1095
+ sdk_root: str = typer.Option(
1096
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1097
+ ),
1098
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
1099
+ quiet: bool = typer.Option(
1100
+ False, "--quiet", help="Suppress the non-essential manifest notice."
1101
+ ),
1102
+ verbose: bool = typer.Option(False, "--verbose", hidden=True),
1103
+ no_color: bool = typer.Option(False, "--no-color", hidden=True),
1104
+ non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True),
1105
+ ci: bool = typer.Option(False, "--ci", hidden=True),
1106
+ target: str = typer.Option(None, "--target", hidden=True),
1107
+ all_cores: bool = typer.Option(False, "--all", hidden=True),
1108
+ ) -> None:
1109
+ """Remove this project's build directory and build-state cache."""
1110
+ # The six options above are `clap`'s `GlobalArgs` members that `clean.rs`
1111
+ # accepts and never reads. Declared here purely so the argv SURFACE matches:
1112
+ # `tan clean --no-color` exits 0 on the oracle and, without these, exited 2 as
1113
+ # a Click usage error -- so a customer's `tan clean --ci` in a CI script
1114
+ # cleaned nothing. Hidden from `--help` because they do nothing.
1115
+ #
1116
+ # This gap is PORT-WIDE, not `clean`'s alone -- measured against the Rust
1117
+ # binary, `presets --no-color`, `doctor --no-color` and `sdk current --ci`
1118
+ # each still exit 2 where the oracle exits 0/4/0. Closed here rather than
1119
+ # left because `clean` is the command whose refusal means work that should
1120
+ # have been removed was not; the shared fix (one decorator for every command)
1121
+ # belongs with whoever owns the global-flag surface.
1122
+ del verbose, no_color, non_interactive, ci, target, all_cores
1123
+ json_mode = output_format == "json"
1124
+
1125
+ try:
1126
+ outcome = _run(
1127
+ app_path=app_path,
1128
+ build_root_arg=build_root,
1129
+ dry_run=dry_run,
1130
+ project_arg=project,
1131
+ board_yaml_arg=board_yaml,
1132
+ sdk_root_arg=sdk_root,
1133
+ quiet=quiet,
1134
+ )
1135
+ except Exception as err: # noqa: BLE001
1136
+ # The recurring break this guard exists for: an escaping traceback puts
1137
+ # nothing parseable on stdout and the extension renders an empty panel
1138
+ # with no error at all. CONSTANTS ONLY below -- no call to a helper that
1139
+ # could itself throw, which is how a single fault became a double fault
1140
+ # elsewhere in this port. In particular `resolve_project_paths` is NOT
1141
+ # re-run here: it reads the cwd, and a deleted cwd is one of the ways
1142
+ # `_run` can fail in the first place.
1143
+ outcome = _Outcome(
1144
+ exit_code=ExitCode.INTERNAL_FAILURE,
1145
+ data=_report("", dry_run, [], 0),
1146
+ project=Project(root=None, board_yaml=None),
1147
+ sdk=None,
1148
+ issues=[
1149
+ Issue(
1150
+ "clean.internal-failure",
1151
+ "error",
1152
+ f"clean failed unexpectedly: {err}",
1153
+ )
1154
+ ],
1155
+ text=["clean: internal failure"],
1156
+ )
1157
+
1158
+ # Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407
1159
+ # `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_
1160
+ # divergence`), and `outcome.text` was assembled strictly before any
1161
+ # `Envelope` existed -- so a seam-appended issue reached `--format json`
1162
+ # and was silent on the default text channel (tan-cli#799). Diffed
1163
+ # against `outcome.issues` (by value: `Issue` is a frozen dataclass) so
1164
+ # only what the seam ADDED is rendered.
1165
+ envelope = Envelope(
1166
+ "clean",
1167
+ outcome.project,
1168
+ outcome.data,
1169
+ outcome.issues,
1170
+ outcome.exit_code,
1171
+ sdk=outcome.sdk,
1172
+ )
1173
+ if json_mode:
1174
+ emit(envelope)
1175
+ else:
1176
+ seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues]
1177
+ # stdout is the envelope channel and carries nothing else, in either
1178
+ # mode; stderr carries no contract of its own.
1179
+ for issue in seam_extra:
1180
+ print(f"{issue.severity}: {issue.message}", file=sys.stderr)
1181
+ for line in outcome.text:
1182
+ print(line, file=sys.stderr)
1183
+ raise typer.Exit(int(outcome.exit_code))