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,931 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan faultdecode` -- decode an ARM Cortex-M (ARMv8-M) fault dump.
3
+
4
+ Port of `alp_cli/faultdecode.py` (alp-sdk `scripts/alp_cli/faultdecode.py`).
5
+ Until now `tan faultdecode` was a thin forwarder to `python -m alp_cli
6
+ faultdecode` (`crates/tan-cli/src/commands/sdk_cli.rs`); this is the native
7
+ replacement, so `--json`'s OUTPUT CONTRACT is that forward's own: the SDK's
8
+ `--json` report shape (`fault_detected`/`inputs`/`flags`/`addresses`/
9
+ `root_cause`/`symbols`), unwrapped, on stdout. `sdk_cli.rs`'s success path
10
+ streamed the child's stdio through untouched; `--json` reproduces exactly what
11
+ a caller already received through that pipe, so nothing downstream (a saved
12
+ script, the forwarder itself) observes a change.
13
+
14
+ `--format json` is a DIFFERENT surface, and tan-cli#399 is the decision to
15
+ make it one: it emits tan's `{command,ok,exitCode,project,data,issues}`
16
+ envelope with the report as `data`. `--format json` is what the vscode
17
+ extension drives (`contract/README.md:4-8`), and its `isEnvelope` guard
18
+ (`alp-sdk-vscode src/alpCli/service.ts:705-716`) requires `command`, `ok`,
19
+ `exitCode` and `issues[]` -- the unwrapped report has none of them, so
20
+ `parseEnvelope` returned `null`, `classifyOutcome` saw rc 0, and a successful
21
+ decode rendered as the literal string `Command completed.` with `root_cause`
22
+ on the wire and unreachable. The v0.4.1 oracle maps the global `--format json`
23
+ onto the child's `--json` and therefore prints the raw report for both
24
+ spellings; that divergence is deliberate and is the whole content of the fix.
25
+ `--json` wins when both are given, so an existing caller cannot have its shape
26
+ changed by a `--format json` something else in the chain added.
27
+
28
+ A firmware engineer pastes the fault registers a HardFault prints (CFSR, HFSR,
29
+ optionally DFSR, BFAR, MMFAR) and gets back the human-readable cause plus, when
30
+ an ELF is supplied, the faulting symbol and `file:line` -- instead of staring at
31
+ CFSR hex.
32
+
33
+ The decode/parse/render core lives in `tan.core.faultdecode` (pure, no I/O);
34
+ this module owns the CLI surface plus the two things that touch the outside
35
+ world: reading a pasted dump (`--file`/stdin) and best-effort `addr2line`
36
+ symbolication.
37
+
38
+ It is strictly HW-free: pure register arithmetic. Symbolication is best-effort
39
+ and optional -- if no ELF or no `addr2line`-class tool is found it is skipped,
40
+ never fatal. Exit code is 0 for any successful analysis (even "no fault flags
41
+ set"); only genuinely bad input (no registers, an unparseable value, or a bad
42
+ `--elf`/`--file` path) is nonzero (`ExitCode.VALIDATION_FAILURE`, matching the
43
+ original's `click.BadParameter`/`click.UsageError`, both exit 2).
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import json as _json
49
+ import os
50
+ import queue
51
+ import subprocess
52
+ import sys
53
+ import threading
54
+ import time
55
+ from dataclasses import dataclass
56
+ from pathlib import Path
57
+
58
+ import typer
59
+
60
+ from tan.commands.doctor_cmd import on_path
61
+ from tan.core.faultdecode import (
62
+ Symbol,
63
+ decode,
64
+ parse_dump,
65
+ report_to_json,
66
+ render_human,
67
+ )
68
+ from tan.core.inert import NOT_APPLICABLE, inert_help
69
+ from tan.core.subprocess_env import spawn_env
70
+ from tan.env import no_color_requested, stdin_is_tty
71
+ from tan.envelope import Envelope, Issue, Project, emit
72
+ from tan.exit_codes import ExitCode
73
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
74
+
75
+ _ADDR2LINE_TOOLS = ("arm-zephyr-eabi-addr2line", "llvm-addr2line", "addr2line")
76
+
77
+
78
+ def resolve_symbol(addr: int, elf: Path) -> Symbol | None:
79
+ """Resolve ``addr`` to ``func`` + ``file:line`` via an addr2line-class tool.
80
+
81
+ Tries ``arm-zephyr-eabi-addr2line`` then ``llvm-addr2line`` then plain
82
+ ``addr2line``. Returns ``None`` (caller skips gracefully) if no tool is on
83
+ PATH or the lookup fails -- symbolication is a convenience, never required.
84
+
85
+ Uses `doctor_cmd.on_path`, NOT `shutil.which` (tan-cli#503): on Windows,
86
+ `shutil.which` inserts `os.curdir` ahead of `$PATH`, so an
87
+ `addr2line.exe`/`llvm-addr2line.exe`/`arm-zephyr-eabi-addr2line.exe`
88
+ sitting at the root of a checked-out project is reported as "available".
89
+ Spawning it by bare NAME afterwards (rather than the resolved absolute
90
+ path) would reopen the same hole a second way -- `CreateProcess`'s own
91
+ current-directory-first search -- even past a hardened probe, so the
92
+ resolved absolute path from `on_path` is what gets spawned below.
93
+
94
+ Since tan-cli#532 that is no longer a second pattern: `on_path` delegates
95
+ to `tan.core.tool_lookup.resolve_tool`, the same lookup `flash_cmd`,
96
+ `size_cmd` and `build/execute.py` call, so every tool probe in tan now
97
+ resolves identically. This docstring previously said those three
98
+ hand-rolled their own walks returning a bare `bool` -- true when it was
99
+ written, false since #567 consolidated them, and stated in the past tense
100
+ here so the next reader does not act on the older shape.
101
+ """
102
+ tool = next((p for t in _ADDR2LINE_TOOLS if (p := on_path(t))), None)
103
+ if tool is None or not elf.is_file():
104
+ return None
105
+ try:
106
+ proc = subprocess.run(
107
+ [tool, "-f", "-C", "-e", str(elf), f"0x{addr:x}"],
108
+ capture_output=True, text=True, timeout=15, env=spawn_env(), check=False,
109
+ )
110
+ except (OSError, subprocess.SubprocessError): # pragma: no cover - env-dependent
111
+ return None
112
+ lines = [ln.strip() for ln in (proc.stdout or "").splitlines() if ln.strip()]
113
+ if not lines:
114
+ return None
115
+ func = lines[0]
116
+ location = lines[1] if len(lines) > 1 else "??:?"
117
+ if func in ("??", "") and location in ("??:?", "??:0", ":?"):
118
+ return None # tool ran but knows nothing about this address
119
+ return Symbol(addr=addr, func=func or "??", location=location or "??:?")
120
+
121
+
122
+ def _use_color(no_color: bool) -> bool:
123
+ """`alp_cli.diagnostic._use_color(False if no_color else None)`: `--no-color`
124
+ forces off, else `NO_COLOR` forces off, else follow stdout's own tty-ness --
125
+ stdout, not stderr, because this command's human text goes to stdout (the
126
+ original's `click.echo` default), matching the forwarded SDK behaviour this
127
+ replaces."""
128
+ if no_color:
129
+ return False
130
+ if no_color_requested():
131
+ return False
132
+ try:
133
+ return sys.stdout.isatty()
134
+ except (AttributeError, ValueError):
135
+ return False
136
+
137
+
138
+ class NegativeRegisterValue(Exception):
139
+ """A fault register was given a negative value (tan-cli#616).
140
+
141
+ Not a `typer.BadParameter`: this refusal carries a REGISTERED issue code
142
+ (`faultdecode.invalid-register-value`) so a `--format json` consumer can
143
+ branch on it, the same way `faultdecode.no-registers` already lets it tell
144
+ "nothing to analyse" from a tan crash. `BadParameter` would land on
145
+ `cli.main`'s generic `command: "cli"` / `cli.parse-error` fallback instead
146
+ -- the two-paths-of-one-verb disagreement tan-cli#399 closed everywhere
147
+ else in this command.
148
+ """
149
+
150
+ def __init__(self, option: str, value: str, *, too_wide: bool = False) -> None:
151
+ self.option = option
152
+ self.value = value
153
+ self.message = (
154
+ f"--{option}: {value!r} does not fit in 32 bits -- a fault register is "
155
+ "a 32-bit word, so this is likely a mistyped extra digit."
156
+ if too_wide
157
+ else f"--{option}: {value!r} is negative -- a fault register value is "
158
+ "unsigned, so there is nothing to decode. Pass the value exactly as "
159
+ "the dump printed it (hex, with or without a 0x prefix)."
160
+ )
161
+ super().__init__(self.message)
162
+
163
+
164
+ def _parse_hexint(option: str, value: str | None) -> int | None:
165
+ """A fault-register value, hex by default: these are CPU status registers,
166
+ always read in hex, so a bare ``8200`` means ``0x8200`` (not decimal 8200)
167
+ and ``0x8200`` works too. Mirrors the original's `_HexInt.convert`.
168
+
169
+ A NEGATIVE value is refused (tan-cli#616). `int(text, 16)` happily accepts
170
+ a leading `-`, and everything downstream then treats the result as a
171
+ register word: `--cfsr=-8200` rendered `"0x-0008200"` (not a hex integer in
172
+ any sense), decoded twelve flags that are not set out of Python's infinite
173
+ two's-complement sign extension, concluded "Stack overflow", and exited 0.
174
+ A register is unsigned by construction, so a sign is a user error and the
175
+ only correct answer is a refusal -- confident nonsense at exit 0 is the
176
+ worst possible one for a tool whose entire output is a diagnosis.
177
+ """
178
+ if value is None:
179
+ return None
180
+ text = value.strip()
181
+ try:
182
+ # base 16 accepts an optional 0x prefix and a bare hex run alike.
183
+ parsed = int(text, 16)
184
+ except ValueError as err:
185
+ raise typer.BadParameter(
186
+ f"{value!r} is not a valid integer (try 0x...)", param_hint=f"--{option}"
187
+ ) from err
188
+ if parsed < 0:
189
+ raise NegativeRegisterValue(option, value)
190
+ if parsed > 0xFFFFFFFF:
191
+ raise NegativeRegisterValue(option, value, too_wide=True)
192
+ return parsed
193
+
194
+
195
+ def _refuse(
196
+ code: str,
197
+ message: str,
198
+ *,
199
+ envelope_mode: bool,
200
+ extra_issues: list[Issue] | None = None,
201
+ ) -> typer.Exit:
202
+ """Build `faultdecode`'s refusal on whichever surface the caller asked for,
203
+ and return the `typer.Exit` for the caller to `raise`.
204
+
205
+ One place, because the refusals have to agree with the success path AND
206
+ with each other about whether stdout is an envelope (tan-cli#399): a second
207
+ refusal that wrote plain text under `--format json` would hand the consumer
208
+ `command: "cli"` here and `command: "faultdecode"` one invocation later,
209
+ which is the exact defect that issue closed for the first one.
210
+
211
+ `extra_issues` (tan-cli#537 constraint 4): a bound firing on the implicit
212
+ stdin read is announced here TOO, not only on the success path -- a bound
213
+ can fire with bytes read but no complete register set, and that refusal
214
+ used to say nothing about the truncation at all. Included in the envelope
215
+ issues[] AND echoed to stderr in every mode (not just `--format json`),
216
+ same as the success path's own announcement.
217
+ """
218
+ for issue in extra_issues or ():
219
+ typer.echo(f"Warning: {issue.message}", err=True)
220
+ if envelope_mode:
221
+ emit(
222
+ Envelope(
223
+ "faultdecode",
224
+ Project(root=None, board_yaml=None),
225
+ None,
226
+ [*(extra_issues or ()), Issue(code, "error", message)],
227
+ ExitCode.VALIDATION_FAILURE,
228
+ )
229
+ )
230
+ else:
231
+ typer.echo(f"Error: {message}", err=True)
232
+ return typer.Exit(int(ExitCode.VALIDATION_FAILURE))
233
+
234
+
235
+ #: Chunk size for each background stdin read. `.read1(n)` (see
236
+ #: `_read_stdin_bounded` for why not `.read(n)`) never blocks trying to fill
237
+ #: this, so its exact value only bounds worst-case per-chunk latency, not
238
+ #: correctness -- 64 KiB is the same figure `shutil.copyfileobj` defaults to.
239
+ _STDIN_CHUNK_BYTES = 65536
240
+
241
+ #: Idle bound (tan-cli#537): how long the background reader waits, after
242
+ #: either the LAST chunk that arrived or start (if none yet), before
243
+ #: deciding no more is coming. Reset on every chunk -- this is what lets a
244
+ #: slow-but-steady producer (a serial capture, a script draining a device)
245
+ #: read in full, which a TOTAL budget (attempts 2 and 6/7) cannot do.
246
+ #:
247
+ #: Measured, not argued: an ad-hoc bench script (tan-cli#537, not checked in) fed a
248
+ #: real OS pipe 26 lines with a 0.24s `time.sleep` between each and recorded
249
+ #: the gaps the reader thread actually observed -- max 0.250s under this
250
+ #: environment's own scheduling jitter, essentially the nominal 0.24s with no
251
+ #: slack to spare. 2.0s is that measured worst case with roughly 8x headroom,
252
+ #: not a number chosen by reasoning about the idle window in the abstract --
253
+ #: that reasoning is exactly what produced the reverted "20x the idle window"
254
+ #: comment in attempt 6/7, which was arithmetically false for this same
255
+ #: shape.
256
+ _STDIN_IDLE_TIMEOUT_S = 2.0
257
+
258
+ #: Byte cap (tan-cli#537): a hard ceiling on accumulated stdin bytes. No
259
+ #: earlier attempt had one -- unguarded, `yes "CFSR: 0x00008200" | tan
260
+ #: faultdecode` reached 409.7 MB RSS over 9.59s (issue #537, measured). A
261
+ #: real pasted fault dump is at most a few KB; 1 MiB is roughly three orders
262
+ #: of magnitude below that measured unguarded growth while staying two-plus
263
+ #: orders of magnitude above any legitimate paste. Bounds the ACCUMULATED
264
+ #: BUFFER itself (checked after every chunk), not the read loop -- a large
265
+ #: total-cap alone would still let the buffer grow unbounded until it fired.
266
+ _STDIN_BYTE_CAP = 1_048_576
267
+
268
+ #: Total bound (tan-cli#537): a backstop so a producer that never goes idle
269
+ #: for `_STDIN_IDLE_TIMEOUT_S` and never reaches `_STDIN_BYTE_CAP` (a
270
+ #: continuous writer trickling below both) still terminates. NOT the primary
271
+ #: defence -- the idle bound is, for exactly the reason attempts 6/7 got a
272
+ #: fixed total wrong: any fixed total remains capable of truncating a
273
+ #: sufficiently slow legitimate producer. 30s is ~5x the 6.2s measured
274
+ #: slow-but-steady shape (the same ad-hoc bench), which is comfortably
275
+ #: inside it because the IDLE bound is what actually governs that case (each
276
+ #: gap is ~0.24s, far under `_STDIN_IDLE_TIMEOUT_S`); this total exists only
277
+ #: to catch the case the idle bound cannot. Whichever bound fires, the
278
+ #: read still ANNOUNCES it (`_stdin_bound_message`) rather than silently
279
+ #: discarding -- that announcement, not the value chosen here, is what makes
280
+ #: a truncation recoverable instead of a confident wrong root cause.
281
+ _STDIN_TOTAL_TIMEOUT_S = 30.0
282
+
283
+ #: Bound on `_read_stdin_bounded`'s internal `chunk_queue` (tan-cli#537
284
+ #: follow-up): the byte cap above bounds the RETURNED buffer, but the
285
+ #: background reader thread is ABANDONED, not joined, once any bound fires
286
+ #: (see `_read_stdin_bounded`'s own docstring on why) -- an unbounded
287
+ #: `queue.Queue()` let that abandoned thread keep calling `os.read` + `put`
288
+ #: against a producer that never stops (the `yes` shape) with nobody ever
289
+ #: calling `.get()` again, growing without limit. Measured from the moment
290
+ #: the byte cap fired against a real `yes` pipe: 640.1 MB at +0.5s, 1059.6 MB
291
+ #: at +1.0s, 2001.9 MB at +2.0s, 3845.8 MB at +4.0s -- bounded today only by
292
+ #: how fast the process happens to exit, which is not a bound. 16 chunks is
293
+ #: `_STDIN_BYTE_CAP // _STDIN_CHUNK_BYTES` -- exactly enough that the queue
294
+ #: never has to reject a chunk before the byte cap itself would have ended
295
+ #: the main loop anyway -- plus headroom for ordinary scheduling jitter
296
+ #: between the reader thread producing and the main loop's `.get()`
297
+ #: consuming, so this never discards a chunk the main loop was still going
298
+ #: to want.
299
+ _STDIN_DRAIN_QUEUE_MAXSIZE = (_STDIN_BYTE_CAP // _STDIN_CHUNK_BYTES) + 16
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class _StdinReadOutcome:
304
+ """Result of one bounded background stdin BYTE read (tan-cli#537)."""
305
+
306
+ #: Raw bytes accumulated before whichever bound fired (or clean EOF).
307
+ data: bytes
308
+ #: Which bound fired, or `None` on a clean EOF (the writer closed the
309
+ #: pipe on its own, no bound needed to intervene at all).
310
+ bound: str | None # None | "idle" | "byte-cap" | "total-cap"
311
+
312
+
313
+ def _read_stdin_bounded(
314
+ buffer: object,
315
+ *,
316
+ idle_s: float = _STDIN_IDLE_TIMEOUT_S,
317
+ byte_cap: int = _STDIN_BYTE_CAP,
318
+ total_s: float = _STDIN_TOTAL_TIMEOUT_S,
319
+ chunk_bytes: int = _STDIN_CHUNK_BYTES,
320
+ ) -> _StdinReadOutcome:
321
+ """Read `buffer` (a binary stream, e.g. `sys.stdin.buffer`) to bytes,
322
+ bounded three ways at once (tan-cli#537): an IDLE timeout that resets on
323
+ every chunk, a hard BYTE CAP on the accumulated buffer, and a TOTAL
324
+ timeout as a backstop. All state (the queue, the buffer, the deadlines)
325
+ is local to this call -- tan-cli#537's own module-global `_PREREAD_STDIN`
326
+ stash is what let one invocation's empty read leak into the next
327
+ in-process call; nothing here survives past the `return`.
328
+
329
+ ONE mechanism on every platform: a single daemon thread looping a
330
+ single-syscall chunk read and pushing each chunk to a `queue.Queue`,
331
+ with EOF pushed in-band as `b""` -- never inferred from a clock. This
332
+ deletes the select()-on-POSIX/thread-on-Windows split the previous
333
+ reader carried: `select()` is WinSock-only on Windows (anonymous pipes
334
+ are not selectable there at all), and neither `PeekNamedPipe` nor a
335
+ console wait handle answers "will this read terminate?" -- only "is a
336
+ byte available?", a different question. A chunked thread reader needs
337
+ none of that and behaves identically everywhere.
338
+
339
+ Never `buffer.read(n)`: `io.BufferedReader.read(n)` blocks issuing
340
+ repeated raw reads until it collects `n` bytes OR hits EOF -- it does NOT
341
+ return early just because some bytes are already available. Measured
342
+ (the same ad-hoc bench): with `.read(65536)` a producer
343
+ writing 26 lines with 0.24s gaps between them delivered exactly ONE
344
+ chunk containing the whole dump, at EOF, 6.29s after the first byte --
345
+ the per-chunk idle-reset this function exists to provide never had
346
+ anything to reset against, silently degrading to the "wait for the
347
+ whole thing" case attempt 6/7 already got a total budget wrong for.
348
+
349
+ The chunk primitive is `os.read(fd, chunk_bytes)` on `buffer`'s real file
350
+ descriptor when it has one (a genuine `sys.stdin.buffer` over a pipe,
351
+ file, or console), falling back to `buffer.read1(chunk_bytes)` only when
352
+ it does not (`typer.testing.CliRunner`'s in-memory `BytesIO`-backed
353
+ stdin double, whose `.fileno()` raises `io.UnsupportedOperation` --
354
+ `CliRunner` hands the command an already-complete buffer, so `.read1()`
355
+ there returns immediately and reaches its own in-band EOF quickly; it
356
+ never blocks on a real held-open pipe, so it carries none of the risk
357
+ the next paragraph describes). Both return AT MOST ONE underlying
358
+ system read's worth of data rather than trying to fill `chunk_bytes` --
359
+ the same bench run measured 27 `.read1()` chunks, one per line plus the
360
+ terminating empty read, with gaps at 0.239-0.250s, matching the
361
+ writer's own 0.24s sleep, and `os.read` shares that same one-syscall
362
+ contract by construction (it does not go through any buffering layer
363
+ at all).
364
+
365
+ `os.read(fd, ...)` over `buffer.read1(...)` on a REAL stdin is not a
366
+ style choice, it is the fix for a second, more severe defect this
367
+ function's own first cut hit: with the reader parked in
368
+ `buffer.read1()` (a `BufferedReader` method) when the idle bound fires
369
+ and the caller proceeds without joining the thread, a normal process
370
+ exit then aborts -- measured, reproducibly, via
371
+ `test_registers_on_the_command_line_never_wait_for_an_open_stdin_pipe`
372
+ (a REAL subprocess, not `CliRunner`) -- with `Fatal Python error:
373
+ _enter_buffered_busy: could not acquire lock for <_io.BufferedReader
374
+ name='<stdin>'> at interpreter shutdown, possibly due to daemon
375
+ threads` and exit code -6 (SIGABRT): CPython's interpreter-shutdown
376
+ finalizer tries to flush/close `sys.stdin`, which needs the
377
+ `BufferedReader`'s internal per-object lock, and the abandoned thread
378
+ is holding that same lock parked in a real blocking read syscall that
379
+ will never return (the parent still owns the open write end) --
380
+ exactly the "daemon thread parked in a read at interpreter exit" risk
381
+ the design flagged for Windows specifically, reproduced here on Linux
382
+ too. `os.read(fd, n)` is a bare syscall on the raw descriptor with no
383
+ Python-level stream OBJECT and therefore no such lock for the
384
+ finalizer to contend on; the same reproduction, switched to it, exits
385
+ 0 cleanly (verified with a second ad-hoc reproduction script,
386
+ tan-cli#537, not checked in).
387
+
388
+ Termination is never inferred: EOF is the in-band `b""` chunk, the idle
389
+ bound fires only via `queue.Queue.get(timeout=...)` actually elapsing
390
+ with nothing delivered, the byte cap fires only once the accumulated
391
+ total has actually reached it, and the total bound fires only once the
392
+ wall clock has actually run out. Whichever one fires, what already
393
+ arrived is kept (never the discard-on-timeout shape of attempt 1, where
394
+ `got.append(sys.stdin.read())` only ran on `read()` RETURNING, so a join
395
+ timeout threw away every buffered byte).
396
+
397
+ The reader thread is abandoned, not joined, on every exit from the loop
398
+ below except a clean EOF: it may still be parked in a read when this
399
+ function returns (a producer that never closes its end), and it holds
400
+ nothing but stdin, so leaving it for the daemon-thread interpreter-exit
401
+ path to reap is the correct outcome -- the same trade the previous
402
+ reader already made for exactly the same reason, and now safe to make
403
+ (see the `os.read` paragraph above) because nothing it holds blocks
404
+ that exit any more.
405
+ """
406
+ try:
407
+ fd: int | None = buffer.fileno() # type: ignore[attr-defined]
408
+ except (AttributeError, OSError, ValueError):
409
+ fd = None
410
+
411
+ chunk_queue: queue.Queue[bytes] = queue.Queue(maxsize=_STDIN_DRAIN_QUEUE_MAXSIZE)
412
+
413
+ def _read_one_chunk() -> bytes:
414
+ if fd is not None:
415
+ return os.read(fd, chunk_bytes)
416
+ return buffer.read1(chunk_bytes) # type: ignore[attr-defined]
417
+
418
+ def _drain() -> None:
419
+ # `put_nowait` + discard-on-`Full`, not a blocking `put` against a
420
+ # bounded queue: once the main loop below has broken out of its own
421
+ # `while True` (a bound fired) it never calls `.get()` again, so a
422
+ # blocking `put()` here would just move the "abandoned daemon thread
423
+ # parked in a blocking call" risk from the read syscall to the
424
+ # queue's own lock instead of removing it -- the exact shape this
425
+ # function's `os.read` fix (see its docstring) exists to avoid, only
426
+ # one layer up. Discarding keeps this thread doing exactly what it
427
+ # was already doing (repeatedly calling `_read_one_chunk`) with
428
+ # nothing new to block on, so it drains the pipe (the fix a real OS
429
+ # pipe's own backpressure gives a writer) without retaining what it
430
+ # reads once nobody is left to consume it.
431
+ try:
432
+ while True:
433
+ chunk = _read_one_chunk()
434
+ try:
435
+ chunk_queue.put_nowait(chunk)
436
+ except queue.Full:
437
+ pass
438
+ if not chunk:
439
+ return
440
+ except (OSError, ValueError): # pragma: no cover - env-dependent
441
+ try:
442
+ chunk_queue.put_nowait(b"")
443
+ except queue.Full:
444
+ pass
445
+
446
+ reader = threading.Thread(target=_drain, daemon=True)
447
+ reader.start()
448
+
449
+ chunks: list[bytes] = []
450
+ total = 0
451
+ bound: str | None = None
452
+ start = time.monotonic()
453
+ while True:
454
+ remaining_total = total_s - (time.monotonic() - start)
455
+ if remaining_total <= 0:
456
+ bound = "total-cap"
457
+ break
458
+ wait = min(idle_s, remaining_total)
459
+ try:
460
+ chunk = chunk_queue.get(timeout=wait)
461
+ except queue.Empty:
462
+ # Which bound actually expired depends on which of the two was
463
+ # the shorter (and therefore limiting) wait, NOT on which one is
464
+ # checked first above: if `remaining_total` was the smaller of
465
+ # the two, the wait timed out because the TOTAL deadline was
466
+ # imminent, not because the reader went idle for a full
467
+ # `idle_s` -- mislabelling that as "idle" would misreport a
468
+ # total-cap termination on a producer whose chunks are only
469
+ # slightly slower than `idle_s - remaining_total`.
470
+ bound = "total-cap" if wait < idle_s else "idle"
471
+ break
472
+ if not chunk:
473
+ break # clean EOF -- no bound fired, whatever arrived is complete
474
+ chunks.append(chunk)
475
+ total += len(chunk)
476
+ if total >= byte_cap:
477
+ bound = "byte-cap"
478
+ break
479
+
480
+ data = b"".join(chunks)
481
+ if bound == "byte-cap":
482
+ data = data[:byte_cap]
483
+ return _StdinReadOutcome(data=data, bound=bound)
484
+
485
+
486
+ def _stdin_bound_message(bound: str, bytes_read: int) -> str:
487
+ """The stderr/`issues[]` announcement for whichever bound
488
+ `_read_stdin_bounded` fired (tan-cli#537 constraint 4): names the bound
489
+ BY NAME and the remedy -- `--file -` is already the explicit
490
+ read-to-EOF path, the unbounded opt-in a truncated caller should be told
491
+ to reach for. A truncated decode the engineer is told about is a
492
+ recoverable inconvenience; a silent one is a confident wrong root
493
+ cause, which is the whole cost this design refuses to pay silently."""
494
+ remedy = "use `--file -` to read the complete dump instead (reads to EOF, no bound)"
495
+ if bound == "idle":
496
+ return (
497
+ f"stdin idle for {_STDIN_IDLE_TIMEOUT_S:g}s after {bytes_read} byte(s) "
498
+ f"without reaching EOF -- decoded what arrived; {remedy}."
499
+ )
500
+ if bound == "byte-cap":
501
+ return (
502
+ f"stdin exceeded the {_STDIN_BYTE_CAP}-byte cap without reaching EOF -- "
503
+ f"decoded the first {_STDIN_BYTE_CAP} bytes only; {remedy}."
504
+ )
505
+ if bound == "total-cap":
506
+ return (
507
+ f"stdin did not reach EOF within {_STDIN_TOTAL_TIMEOUT_S:g}s -- decoded "
508
+ f"the {bytes_read} byte(s) read so far; {remedy}."
509
+ )
510
+ raise AssertionError(f"unknown stdin bound: {bound!r}") # pragma: no cover
511
+
512
+
513
+ @dataclass(frozen=True)
514
+ class ImplicitStdinResult:
515
+ """Result of the IMPLICIT (unnamed, auto-consume) stdin read (tan-cli#537)
516
+ -- `... | tan faultdecode`, no `--file` on the command line at all.
517
+
518
+ Never raises: a detached/replaced/absent stdin degrades to
519
+ `attempted=False, text=""`, the same "nothing to offer" answer a real
520
+ closed pipe gives, because the implicit path's whole job is "just work
521
+ when there is something to read, fall through cleanly when there is
522
+ not" -- `--file -` is the surface that raises on a stdin it cannot
523
+ serve, because THAT read was named explicitly.
524
+ """
525
+
526
+ #: The decoded dump text -- "" when nothing was read at all.
527
+ text: str
528
+ #: Which bound fired during the read, or `None` when either no read was
529
+ #: attempted or the read reached a clean EOF with no bound needed.
530
+ bound: str | None
531
+ #: Whether a background read was actually started (False for a TTY, a
532
+ #: detached/`None` stdin, or a text-only replacement with no `.buffer`).
533
+ attempted: bool
534
+ #: How many bytes were actually read (0 when not attempted, or when an
535
+ #: attempted read got nothing before its idle bound fired).
536
+ bytes_read: int
537
+
538
+
539
+ def _read_implicit_stdin() -> ImplicitStdinResult:
540
+ """The IMPLICIT stdin reader tan-cli#537 exists to redesign.
541
+
542
+ Reads `sys.stdin.buffer` as BYTES via `_read_stdin_bounded`, accumulates,
543
+ and decodes EXACTLY ONCE at the end with `bytes.decode("utf-8",
544
+ errors="ignore")` -- byte-identical to what `--file <path>` already does
545
+ via `Path.read_text(encoding="utf-8", errors="ignore")` (same codec, same
546
+ error handler), so decode parity with `--file` stops being a property
547
+ tested for and becomes one that cannot be violated: there is one decode
548
+ call, on the complete buffer, with the same two operands on both paths.
549
+ This is also what deletes the entire class that produced attempts 3 and
550
+ 5: no chunk boundary exists for a multi-byte sequence to split across (no
551
+ decode happens per chunk at all), so no `UnicodeDecodeError` can be
552
+ raised mid-read for an `except` to swallow, and nothing here ever calls
553
+ `.reconfigure()`, so its `io.UnsupportedOperation` on an already-read
554
+ stream never arises either.
555
+
556
+ Routes the TTY/detached/replaced-stream checks through this repo's own
557
+ guarded probes rather than hand-rolling a second copy of either
558
+ (tan-cli#488's `_TeeStderr` class of bug): `stdin_is_tty()` -- not a bare
559
+ `sys.stdin.isatty()` -- absorbs both `sys.stdin is None` and a replaced
560
+ stream with no `.isatty()` at all. Reading `.buffer` adds ONE further
561
+ shape neither existing probe covers: a text-only replacement stream
562
+ (e.g. a test harness's `io.StringIO`) has no `.buffer` attribute at all
563
+ -- `getattr(..., "buffer", None)` degrades that to `attempted=False`
564
+ cleanly, matching every other "nothing to offer" case, rather than
565
+ raising `AttributeError` from inside a background thread.
566
+ """
567
+ if sys.stdin is None or stdin_is_tty():
568
+ return ImplicitStdinResult(text="", bound=None, attempted=False, bytes_read=0)
569
+ buf = getattr(sys.stdin, "buffer", None)
570
+ if buf is None:
571
+ return ImplicitStdinResult(text="", bound=None, attempted=False, bytes_read=0)
572
+ outcome = _read_stdin_bounded(buf)
573
+ text = outcome.data.decode("utf-8", errors="ignore")
574
+ return ImplicitStdinResult(
575
+ text=text, bound=outcome.bound, attempted=True, bytes_read=len(outcome.data)
576
+ )
577
+
578
+
579
+ def _read_dump(file_: str | None) -> str:
580
+ """Read a pasted dump from `--file <path>` or `--file -` (stdin to EOF).
581
+
582
+ Returns `""` when `file_` is `None`: the IMPLICIT auto-consume path
583
+ (no `--file` on the command line at all) is `_read_implicit_stdin`
584
+ above, a SEPARATE function since tan-cli#537 -- folding it into this one
585
+ (the pre-#537 shape) is what let a module-global stash of pre-read bytes
586
+ (`_PREREAD_STDIN`) survive between invocations; splitting it out means
587
+ this function needs no such stash at all.
588
+
589
+ `--file -` is the EXPLICIT unbounded opt-in `_stdin_bound_message` tells
590
+ a truncated implicit-read caller to reach for: it reads to EOF with NO
591
+ idle/byte/total bound, whatever else was on the command line, because
592
+ the caller named this read specifically and a dump has no terminator
593
+ other than EOF. Reads `.buffer` and decodes once with the same
594
+ `errors="ignore"` UTF-8 decode `--file <path>` and the implicit path
595
+ both use, rather than the previous text-layer `sys.stdin.read()`, which
596
+ could itself raise `UnicodeDecodeError` on a non-UTF-8 host and broke
597
+ decode parity a second way.
598
+
599
+ tan-cli#488 round 5 class sweep: `sys.stdin` itself, not just the result
600
+ of calling `.isatty()` on it, can be `None` -- a process launched with
601
+ its standard handles detached (a GUI launcher, a `pythonw`-style spawn,
602
+ or a shell that closed fd 0 before exec). `--file -` names stdin
603
+ explicitly, so a detached stdin (or one replaced by a text-only stream
604
+ with no `.buffer`) is refused with a clear message rather than a raw
605
+ `AttributeError`.
606
+ """
607
+ if file_ is None:
608
+ return ""
609
+ if file_ == "-":
610
+ if sys.stdin is None:
611
+ raise typer.BadParameter(
612
+ "stdin is detached (no standard input handle for this process), "
613
+ "so `--file -` cannot read a dump from it",
614
+ param_hint="--file",
615
+ )
616
+ buf = getattr(sys.stdin, "buffer", None)
617
+ if buf is None:
618
+ raise typer.BadParameter(
619
+ "stdin has no binary buffer to read from (it has been replaced "
620
+ "by a text-only stream), so `--file -` cannot read a dump from it",
621
+ param_hint="--file",
622
+ )
623
+ return buf.read().decode("utf-8", errors="ignore")
624
+ return Path(file_).read_text(encoding="utf-8", errors="ignore")
625
+
626
+
627
+
628
+ def _check_elf_path(value: str | None) -> Path | None:
629
+ """`click.Path(exists=True, dir_okay=False)` equivalent for `--elf`."""
630
+ if value is None:
631
+ return None
632
+ path = Path(value)
633
+ if not path.exists():
634
+ raise typer.BadParameter(f"'{value}' does not exist.", param_hint="--elf")
635
+ if path.is_dir():
636
+ raise typer.BadParameter(f"'{value}' is a directory.", param_hint="--elf")
637
+ return path
638
+
639
+
640
+ def _check_file_path(value: str | None) -> str | None:
641
+ """`click.Path(exists=True, dir_okay=False, allow_dash=True)` equivalent for
642
+ `--file`: `-` is passed through untouched (means stdin, see `_read_dump`)."""
643
+ if value is None or value == "-":
644
+ return value
645
+ path = Path(value)
646
+ if not path.exists():
647
+ raise typer.BadParameter(f"'{value}' does not exist.", param_hint="--file")
648
+ if path.is_dir():
649
+ raise typer.BadParameter(f"'{value}' is a directory.", param_hint="--file")
650
+ return value
651
+
652
+
653
+ def faultdecode(
654
+ ctx: typer.Context,
655
+ cfsr: str = typer.Option(None, "--cfsr", help="Configurable Fault Status Register."),
656
+ hfsr: str = typer.Option(None, "--hfsr", help="HardFault Status Register."),
657
+ dfsr: str = typer.Option(None, "--dfsr", help="Debug Fault Status Register (optional)."),
658
+ bfar: str = typer.Option(None, "--bfar", help="BusFault Address Register."),
659
+ mmfar: str = typer.Option(None, "--mmfar", help="MemManage Fault Address Register."),
660
+ mmfsr: str = typer.Option(
661
+ None, "--mmfsr", help="MemManage sub-register (composed into CFSR)."
662
+ ),
663
+ bfsr: str = typer.Option(None, "--bfsr", help="BusFault sub-register (composed into CFSR)."),
664
+ ufsr: str = typer.Option(
665
+ None, "--ufsr", help="UsageFault sub-register (composed into CFSR)."
666
+ ),
667
+ pc: str = typer.Option(None, "--pc", help="Program Counter (symbolicated with --elf)."),
668
+ lr: str = typer.Option(None, "--lr", help="Link Register (symbolicated with --elf)."),
669
+ elf: str = typer.Option(
670
+ None,
671
+ "--elf",
672
+ help="ELF for --pc/--lr symbolication (best-effort; skipped if no tool/elf).",
673
+ ),
674
+ file_: str = typer.Option(
675
+ None,
676
+ "--file",
677
+ help="Read a pasted dump from this file ('-' for stdin) and grep registers.",
678
+ ),
679
+ as_json: bool = typer.Option(False, "--json", help="Emit a machine-readable JSON report."),
680
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colours."),
681
+ project: str = typer.Option(
682
+ None,
683
+ "--project",
684
+ metavar="PATH",
685
+ help=inert_help(
686
+ "Project root. Not read: faultdecode is pure ARMv8-M register "
687
+ "arithmetic and opens no project.",
688
+ NOT_APPLICABLE,
689
+ ),
690
+ ),
691
+ sdk_root: str = typer.Option(
692
+ None,
693
+ "--sdk-root",
694
+ metavar="PATH",
695
+ help=inert_help(
696
+ "alp-sdk checkout root. Not read: faultdecode is pure ARMv8-M "
697
+ "register arithmetic and drives no alp-sdk checkout.",
698
+ NOT_APPLICABLE,
699
+ ),
700
+ ),
701
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
702
+ board_yaml: str = typer.Option(None, "--board-yaml", hidden=True),
703
+ target: str = typer.Option(None, "--target", hidden=True),
704
+ all_targets: bool = typer.Option(False, "--all", hidden=True),
705
+ verbose: bool = typer.Option(False, "--verbose", hidden=True),
706
+ quiet: bool = typer.Option(False, "--quiet", hidden=True),
707
+ non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True),
708
+ ci: bool = typer.Option(False, "--ci", hidden=True),
709
+ ) -> None:
710
+ """Decode an ARM Cortex-M (ARMv8-M) fault dump.
711
+
712
+ Supply registers as flags, and/or paste a dump via ``--file``/stdin and it
713
+ greps the register names out. Explicit flags win over a parsed dump.
714
+
715
+ `--project`/`--sdk-root`/`--board-yaml`/`--target`/`--all`/`--verbose`/
716
+ `--quiet`/`--non-interactive`/`--ci` are declared, not consumed: this
717
+ command reads no board.yaml and drives no alp-sdk checkout -- it is pure
718
+ ARMv8-M register arithmetic, same as the SDK original it replaces -- but
719
+ the oracle's clap `GlobalArgs` are `global = true`, so every verb
720
+ (`faultdecode` included) accepts all of them; a caller (or a saved
721
+ script) that passes any through unconditionally must not get a parse
722
+ error -- `tan faultdecode --ci ...` exits the same with or without `--ci`
723
+ on the oracle. `--no-color` is the one exception in this group with real
724
+ meaning (see `_use_color`), and `--format` is documented separately
725
+ below.
726
+
727
+ `--format` is accepted BEFORE the subcommand too (`tan --format json
728
+ faultdecode ...`), same as `debug-config`; the root callback records it on
729
+ `ctx.obj` and this option overrides it when repeated after the command
730
+ name. Both positions land on the same shape: tan's own envelope, with the
731
+ decode report as `data`, exactly like `debug-config`'s (tan-cli#399). The
732
+ oracle instead maps the global `--format json` onto the forwarded child's
733
+ `--json` (`crates/tan-cli/src/commands/sdk_cli.rs`) and prints the
734
+ unwrapped report for both spellings -- a deliberate divergence, taken
735
+ because `--format json` is the surface the vscode extension parses and it
736
+ rejects any document without `command`/`ok`/`exitCode`/`issues[]`. The
737
+ unwrapped report is unchanged and still one flag away: `--json`, which
738
+ also wins when both are given.
739
+ """
740
+ del board_yaml, target, all_targets, verbose, quiet, non_interactive, ci
741
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
742
+ # `--json` wins over `--format json`: it is the older, unwrapped surface
743
+ # every saved script and the Rust forwarder use (tan-cli#399).
744
+ envelope_mode = resolved_format == "json" and not as_json
745
+
746
+ elf_path = _check_elf_path(elf)
747
+ file_value = _check_file_path(file_)
748
+
749
+ # 1. Gather registers. The FLAGS are parsed first, before any dump is read
750
+ # (tan-cli#388): reading stdin first meant `--cfsr 0x8200` -- the
751
+ # primary documented invocation, where no dump is needed or wanted --
752
+ # still blocked on a stdin nobody was ever going to write to.
753
+ try:
754
+ cfsr_i = _parse_hexint("cfsr", cfsr)
755
+ hfsr_i = _parse_hexint("hfsr", hfsr)
756
+ dfsr_i = _parse_hexint("dfsr", dfsr)
757
+ bfar_i = _parse_hexint("bfar", bfar)
758
+ mmfar_i = _parse_hexint("mmfar", mmfar)
759
+ mmfsr_i = _parse_hexint("mmfsr", mmfsr)
760
+ bfsr_i = _parse_hexint("bfsr", bfsr)
761
+ ufsr_i = _parse_hexint("ufsr", ufsr)
762
+ pc_i = _parse_hexint("pc", pc)
763
+ lr_i = _parse_hexint("lr", lr)
764
+ except NegativeRegisterValue as err:
765
+ raise _refuse(
766
+ "faultdecode.invalid-register-value", err.message, envelope_mode=envelope_mode
767
+ ) from None
768
+
769
+ # Read the dump UNCONDITIONALLY (tan-cli#503, defect 1) -- not gated on
770
+ # whether any register flag was also given. A prior version skipped this
771
+ # read whenever ANY of cfsr/hfsr/dfsr/bfar/mmfar/mmfsr/bfsr/ufsr was
772
+ # supplied, which broke the command's own documented contract ("Explicit
773
+ # flags win over a parsed dump" only holds if the dump is still read when
774
+ # flags are present) two ways: a piped CFSR/BFAR was silently dropped in
775
+ # favour of the flag's registers alone (measured: `... | tan faultdecode
776
+ # --hfsr 0x40000000` reported a self-contradictory "Forced HardFault ...
777
+ # its own status bits are clear" while the piped CFSR=0x00008200/
778
+ # BFAR=0xdeadbeef it never read said otherwise), and --bfar/--mmfar
779
+ # counted as "a register was given" without satisfying the cfsr/hfsr/dfsr
780
+ # gate below, so the dump was skipped AND the command still refused with
781
+ # `faultdecode.no-registers`.
782
+ #
783
+ # `--file` (path or `-`) is explicit and unbounded; with no `--file` at
784
+ # all the IMPLICIT path (tan-cli#537) reads `sys.stdin.buffer` on a
785
+ # background thread bounded three ways (idle/byte-cap/total, see
786
+ # `_read_stdin_bounded`), so making this unconditional does not reopen
787
+ # tan-cli#388's unbounded hang -- measured,
788
+ # `test_registers_on_the_command_line_never_wait_for_an_open_stdin_pipe`
789
+ # (a real held-open OS pipe, not `CliRunner`) still returns in well under
790
+ # a second, not the 20 s bound it fails at.
791
+ implicit_bound: str | None = None
792
+ implicit_bytes = 0
793
+ implicit_silent = False
794
+ if file_value is not None:
795
+ dump_text = _read_dump(file_value)
796
+ else:
797
+ implicit = _read_implicit_stdin()
798
+ dump_text = implicit.text
799
+ implicit_bound = implicit.bound
800
+ implicit_bytes = implicit.bytes_read
801
+ implicit_silent = implicit.attempted and implicit.bound == "idle" and implicit.bytes_read == 0
802
+ # tan-cli#537 constraint 4: whichever bound fired on the IMPLICIT stdin
803
+ # read, announce it -- on stderr in EVERY mode (not only `--format json`'s
804
+ # `issues[]`), naming the bound and the `--file -` remedy. Only when
805
+ # something was actually read: a bound firing on zero bytes is the
806
+ # separate "silent holder" refusal (`implicit_silent` above), not a
807
+ # truncation. Built HERE, before the no-registers check below, so a bound
808
+ # that fires with bytes read but no complete register set still gets
809
+ # this announcement on the refusal path -- not only on the success path
810
+ # that used to be the only place this list was built.
811
+ stdin_issues: list[Issue] = []
812
+ if implicit_bound is not None and implicit_bytes > 0:
813
+ stdin_bound_message = _stdin_bound_message(implicit_bound, implicit_bytes)
814
+ stdin_issues.append(Issue("faultdecode.stdin-truncated", "warning", stdin_bound_message))
815
+ parsed: dict[str, int] = parse_dump(dump_text) if dump_text else {}
816
+
817
+ def pick(name: str, flag_val: int | None) -> int | None:
818
+ return flag_val if flag_val is not None else parsed.get(name)
819
+
820
+ cfsr_v = pick("cfsr", cfsr_i)
821
+ # Compose CFSR from explicit sub-registers when no combined CFSR was given.
822
+ if cfsr_i is None and (mmfsr_i is not None or bfsr_i is not None or ufsr_i is not None):
823
+ cfsr_v = (
824
+ ((mmfsr_i or 0) & 0xFF)
825
+ | (((bfsr_i or 0) & 0xFF) << 8)
826
+ | (((ufsr_i or 0) & 0xFFFF) << 16)
827
+ )
828
+
829
+ hfsr_v = pick("hfsr", hfsr_i)
830
+ dfsr_v = pick("dfsr", dfsr_i)
831
+ bfar_v = pick("bfar", bfar_i)
832
+ mmfar_v = pick("mmfar", mmfar_i)
833
+ pc_v = pick("pc", pc_i)
834
+ lr_v = pick("lr", lr_i)
835
+
836
+ # No status registers at all => bad input (this is an analysis tool, but it
837
+ # needs *something* to analyse). Exit nonzero with a usage hint.
838
+ if cfsr_v is None and hfsr_v is None and dfsr_v is None:
839
+ if implicit_silent:
840
+ # tan-cli#537: distinguishes "no stdin at all" (TTY, detached, a
841
+ # closed pipe already at EOF) from "a pipe WAS open and offered
842
+ # nothing" -- today's refusal could not tell the two apart, and
843
+ # the second one is the shape a caller can actually fix by
844
+ # waiting longer (`--file -`) rather than by piping something in
845
+ # the first place.
846
+ no_registers = (
847
+ "no fault registers supplied, and stdin was open but silent for "
848
+ f"{_STDIN_IDLE_TIMEOUT_S:g}s -- pass --cfsr/--hfsr/--dfsr, or use "
849
+ "`--file -` to wait for a dump indefinitely."
850
+ )
851
+ elif stdin_issues:
852
+ # tan-cli#537 constraint 4's hole: a bound fired WITH bytes read
853
+ # (unlike `implicit_silent` above, which is zero bytes), but
854
+ # nothing in what arrived parsed into a complete register set.
855
+ # The plain "no fault registers supplied" wording below would
856
+ # tell this caller they supplied nothing at all, when tan in
857
+ # fact stopped reading part-way through a real dump -- distinct
858
+ # from "no dump arrived" and the exact misdirection the issue
859
+ # calls out as worse than the truncation itself.
860
+ no_registers = (
861
+ "no fault registers supplied -- stdin delivered data, but tan "
862
+ f"stopped reading part-way through it: {stdin_issues[0].message}"
863
+ )
864
+ else:
865
+ no_registers = (
866
+ "no fault registers supplied -- pass --cfsr/--hfsr/--dfsr "
867
+ "or pipe a dump via --file/-/stdin."
868
+ )
869
+ # The refusal has to agree with the success path about whether stdout is
870
+ # an envelope (tan-cli#399); leaving it to `cli.main`'s generic fallback
871
+ # gave the consumer `command: "cli"` here and `command: "faultdecode"`
872
+ # one invocation later. `_refuse` is that agreement, shared with the
873
+ # negative-register refusal above. `extra_issues=stdin_issues` is this
874
+ # branch's own fix for constraint 4's hole: without it, a bound firing
875
+ # on a would-be refusal announced nowhere at all.
876
+ raise _refuse(
877
+ "faultdecode.no-registers",
878
+ no_registers,
879
+ envelope_mode=envelope_mode,
880
+ extra_issues=stdin_issues or None,
881
+ )
882
+
883
+ report = decode(
884
+ cfsr=cfsr_v or 0,
885
+ hfsr=hfsr_v or 0,
886
+ dfsr=dfsr_v or 0,
887
+ bfar=bfar_v,
888
+ mmfar=mmfar_v,
889
+ )
890
+
891
+ # 2. Best-effort symbolication (optional, never fatal).
892
+ symbols: dict[str, Symbol] = {}
893
+ if elf_path is not None:
894
+ for which, addr in (("pc", pc_v), ("lr", lr_v)):
895
+ if addr is not None:
896
+ sym = resolve_symbol(addr, elf_path)
897
+ if sym is not None:
898
+ symbols[which] = sym
899
+
900
+ # `stdin_issues` was already built above (before the no-registers check),
901
+ # so the same list -- and the same announcement -- covers both the
902
+ # refusal path (via `_refuse`'s `extra_issues`) and this success path.
903
+ for stdin_issue in stdin_issues:
904
+ typer.echo(f"Warning: {stdin_issue.message}", err=True)
905
+
906
+ if as_json:
907
+ typer.echo(_json.dumps(report_to_json(report, symbols or None), indent=2))
908
+ elif envelope_mode:
909
+ # `project` is hardcoded null/null, not resolved: this command reads no
910
+ # board.yaml and drives no checkout (see the docstring's
911
+ # declared-not-consumed list), so reporting a root here would claim a
912
+ # resolution that never happened.
913
+ emit(
914
+ Envelope(
915
+ "faultdecode",
916
+ Project(root=None, board_yaml=None),
917
+ report_to_json(report, symbols or None),
918
+ stdin_issues,
919
+ ExitCode.SUCCESS,
920
+ )
921
+ )
922
+ else:
923
+ color = _use_color(no_color)
924
+ typer.echo(render_human(report, symbols or None, color))
925
+ if pc_v is not None and elf_path is None:
926
+ typer.echo(
927
+ " (note: --pc given without --elf -- pass --elf <app.elf> to resolve the symbol)",
928
+ err=True,
929
+ )
930
+
931
+ # Analysis tool: a successful decode is always exit 0 (even "no flags set").