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,2425 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan debug-config` -- generate (or preview) a VS Code launch.json entry.
3
+
4
+ Port of `crates/tan-cli/src/commands/debug_config.rs`. Build a launch draft for
5
+ the target class + server, resolve what this project's own build already knows
6
+ (#66), then either preview it (`--preview`) or merge it into
7
+ `<workspace>/.vscode/launch.json`. A malformed existing launch.json -> exit 5;
8
+ a failed write -> exit 3.
9
+
10
+ A write also reads and (best-effort) rewrites one more file:
11
+ `<workspace>/.alp/debug-launch-provenance.json` (tan-cli#518) -- a
12
+ content-hash record of which `configFiles`/`setupCommands` list entries THIS
13
+ command itself wrote, so a LATER merge can tell its own prior output apart
14
+ from the customer's without guessing from position. See `tan.core.
15
+ launch_provenance`'s module docstring for the full design and
16
+ `debug_launch.create_launch_json_write_plan`'s for how it gates the merge.
17
+ Losing that file (deleted, corrupted, never written) never blocks a write and
18
+ never risks customer content -- it only makes the NEXT merge more
19
+ conservative.
20
+
21
+ Everything else this command refuses is the CALLER's own precondition or flag
22
+ value to fix, not a tan crash, so it exits `VALIDATION_FAILURE` (2)
23
+ (tan-cli#462, matching the distinction tan-cli#262 settled for `tan
24
+ validate`): a bad `--target-kind`/`--server`/target+server pairing/`--svd`/
25
+ `--gdbserver-address` value (tan-cli#477); a `--project` that does not exist
26
+ or is not a directory (tan-cli#476 half (a)); and an omitted `--target-kind`
27
+ this project cannot resolve to one target class -- pre-build hardware, an
28
+ explicit `--core` matching no slice, more than one target class with no
29
+ `--core` to narrow them, or no slice whose `os` maps to a target class at all.
30
+ Two more joined that list rather than continuing to succeed silently:
31
+
32
+ * an omitted `--target-kind` on a project offering NO signal at all -- no
33
+ `build/system-manifest.yaml` and no `board.yaml` `som.sku` -- which used to
34
+ fall through to `parse_target_kind(None)`'s `native-host` default and write
35
+ a `native_sim` launch configuration into whatever directory `--project`
36
+ named (tan-cli#476 half (b), `_target_kind_unresolved_failure`);
37
+ * an explicit `--core` naming a core this project's SoM does not have, on a
38
+ project with no build manifest to check it against, which used to leave
39
+ `device` as the literal `<resolved-device>` at exit 0 -- validated against
40
+ the SDK's own published core list instead (tan-cli#477 major 2,
41
+ `_sdk_published_cores`).
42
+
43
+ **The debug profile follows from the target CLASS; the customer never selects an
44
+ OS or a backend.** `--target-kind` names one of four classes, each of which
45
+ implies its adapter (cortex-debug / cppdbg / lldb), its artefact shape and its
46
+ legal server set. There is no `--os` and no `--backend`, and nothing here knows
47
+ a SKU, a device address, an I2C address or a pin name -- the hardware facts live
48
+ in alp-sdk `metadata/`, and the ones a launch config needs arrive through this
49
+ project's OWN build output (`build/system-manifest.yaml` +
50
+ `<build_dir>/zephyr/runners.yaml`, both written beside the build).
51
+
52
+ **Nothing here shells the SDK.** Every input is either an argument, a file
53
+ this project's build already wrote under the workspace, or -- since
54
+ alp-sdk#1026 -- a `metadata/**` file this command reads directly out of a
55
+ resolved SDK checkout (`_fill_debug_probe_identity_from_sdk`, below): a
56
+ best-effort, silent enrichment, exactly like the `board.yaml`/
57
+ `system-manifest.yaml` reads already were. No `alp_project.py`, no
58
+ `alp_orchestrate`, no subprocess of any kind ever runs, and no `sdk` envelope
59
+ key is emitted (tan-cli#111 follow-up -- see
60
+ `_resolve_project_reporting_fields`): the SDK root is resolved and read for
61
+ this one silent enrichment, never reported as a dependency this command
62
+ declares. That is the invariant the port spec records as I-32 and its
63
+ anti-pattern #22: giving a command an alp-sdk-checkout dependency it does not
64
+ declare is a silent regression that no gate catches -- READING metadata files
65
+ is not that; SHELLING the SDK would be.
66
+
67
+ Every failure path emits a coded envelope. A raw traceback on stdout is
68
+ indistinguishable, to the extension, from tan producing nothing at all -- it
69
+ renders an empty panel with no error -- so the outer guard in [`debug_config`]
70
+ converts any unexpected exception into `debug-config.internal-failure` at exit
71
+ 5 rather than letting it escape.
72
+
73
+ **The written profile now says whether it programs the device (tan-cli#945).**
74
+ Two additive facts close the gap alp-sdk-vscode#586 found: a cortex-debug
75
+ `configuration` always carries an explicit `loadFiles` key (naming the
76
+ artefact it programs -- `create_launch_draft` never omits it, matching the
77
+ adapter's own default exactly rather than leaving it implicit), and every
78
+ `data` payload carries `programsDevice: bool` (`tan.core.debug_launch.
79
+ programs_device`), true for zephyr-mcu/baremetal-mcu, false for
80
+ yocto-userspace (a cppdbg attach to an already-deployed gdbserver) and
81
+ native-host (no target hardware exists). Neither field changes which profiles
82
+ this command writes to a workspace it did not author -- a hand-written
83
+ cortex-debug entry is still owed the same inference the extension already
84
+ does for it (see the issue's own "Scope, honestly").
85
+ """
86
+
87
+ from __future__ import annotations
88
+
89
+ import os
90
+ import stat
91
+ from dataclasses import dataclass
92
+ from pathlib import Path
93
+ from typing import Any
94
+
95
+ import typer
96
+
97
+ from tan.commands.build_output import read_sdk_som_and_soc, resolve_project_context
98
+ # The openocd-on-PATH probe is BORROWED, not rebuilt (tan-cli#1179). Both
99
+ # names are private to `support_bundle_cmd`, and importing them is still the
100
+ # right call: `_RUNTIME_EXECUTABLES[OPENOCD]` is the candidate-name table and
101
+ # `_first_on_path` resolves it through `doctor_cmd.on_path` -- the same single
102
+ # lookup `tan support-bundle` reports. A second, independently written probe
103
+ # here would be free to disagree with the bundle a customer attaches to the
104
+ # bug report about this very note, and to miss a second openocd spelling the
105
+ # table later learns. No cycle: nothing under `tan/commands/` imports this
106
+ # module, and `cli.py` already pulls `support_bundle_cmd` into every graph.
107
+ from tan.commands.support_bundle_cmd import _RUNTIME_EXECUTABLES, _first_on_path
108
+ from tan.core.atomic_write import atomic_write_text
109
+ from tan.core.flash_plan import FlashPlanError, fa_str_checked, validate_identifier
110
+ from tan.core.debug_launch import (
111
+ BAREMETAL_MCU,
112
+ GDBSERVER,
113
+ JLINK,
114
+ MANIFEST_OS_BY_TARGET,
115
+ NATIVE_HOST,
116
+ OPENOCD,
117
+ PYOCD,
118
+ SERVER_NONE,
119
+ TARGET_KINDS,
120
+ YOCTO_USERSPACE,
121
+ ZEPHYR_MCU,
122
+ DebugConfigError,
123
+ LaunchResolution,
124
+ apply_launch_resolution,
125
+ create_launch_draft,
126
+ create_launch_json_write_plan,
127
+ explicit_core_unknown_message,
128
+ multi_core_without_core_message,
129
+ fill_debug_probe_identity_gaps,
130
+ infer_target_kind,
131
+ is_unresolved_placeholder,
132
+ launch_preview_document,
133
+ launch_preview_notes,
134
+ load_files_preserved,
135
+ manifest_slices,
136
+ parse_server_kind,
137
+ parse_target_kind,
138
+ programs_device,
139
+ sdk_identity_overwrites,
140
+ sdk_identity_stranded_appends,
141
+ )
142
+ from tan.core import launch_provenance
143
+ from tan.core.global_flags import accept_global_flags
144
+ from tan.core.jsonc_splice import pretty_json
145
+ from tan.core.run import is_native_sim_board, native_sim_exe_beside
146
+ from tan.core.size import resolve_variant
147
+ from tan.core.timestamp import generated_at_iso
148
+ from tan.envelope import Envelope, Issue, Project, emit
149
+ from tan.exit_codes import ExitCode
150
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
151
+
152
+ #: `data.schemaVersion` for this command's payload.
153
+ DATA_SCHEMA_VERSION = "1"
154
+
155
+ #: The `runners.yaml` runner id a debug server reads its arguments from.
156
+ #: `gdbserver`/`none` have no runner: neither is a Zephyr probe runner.
157
+ _RUNNER_ID = {JLINK: "jlink", OPENOCD: "openocd", PYOCD: "pyocd"}
158
+
159
+ #: `--server`'s default when this run just INFERRED `--target-kind` itself
160
+ #: (tan-cli#456) -- never applied over an explicit target, so the pre-existing
161
+ #: "no --server given" refusal there is unchanged.
162
+ _DEFAULT_SERVER_FOR_TARGET = {
163
+ ZEPHYR_MCU: JLINK,
164
+ BAREMETAL_MCU: JLINK,
165
+ YOCTO_USERSPACE: GDBSERVER,
166
+ NATIVE_HOST: SERVER_NONE,
167
+ }
168
+
169
+ #: The launch-configuration JSON key the SDK's debug-probe identity
170
+ #: (`variants[].debug`) resolves for a given server -- absent for a server the
171
+ #: identity has no concept of at all (`gdbserver`/`none`, neither of which
172
+ #: `create_launch_draft` ever pairs with a `variants[].debug` field).
173
+ _SERVER_IDENTITY_FIELD = {JLINK: "device", OPENOCD: "configFiles", PYOCD: "targetId"}
174
+
175
+
176
+ @dataclass
177
+ class _Outcome:
178
+ """What one run produced: the envelope pieces plus the human lines. Built and
179
+ returned, never emitted in place, so the exception guard in [`debug_config`]
180
+ can wrap the whole computation without also catching `typer.Exit` (which is a
181
+ `RuntimeError` subclass, not a `SystemExit`, and would otherwise be swallowed
182
+ by a bare `except Exception`)."""
183
+
184
+ exit_code: ExitCode
185
+ data: dict[str, Any]
186
+ project: Project
187
+ issues: list[Issue]
188
+ text: list[str]
189
+
190
+
191
+ def _generated_at() -> str:
192
+ """`SOURCE_DATE_EPOCH` when set, else now -- `tan.core.timestamp`, which
193
+ NEVER raises. That matters here specifically: this is also called from the
194
+ recovery path of the exception guard in `_emit_outcome`, so a throw
195
+ DOUBLE-FAULTS into a raw traceback with EMPTY stdout.
196
+
197
+ Millisecond precision with a `Z` suffix, matching JavaScript's
198
+ `toISOString()`, because the envelopes are byte-compared against the TS
199
+ CLI's `generatedAt` (and against the committed goldens, which spell
200
+ `1970-01-01T00:00:00.000Z`). A whole-second format would fail all four
201
+ `debug-config` fixtures.
202
+ """
203
+ return generated_at_iso(millis=True)
204
+
205
+
206
+ def _normalise(path: str) -> str:
207
+ """Rust's `normalize_path(cwd.join(p))`: cwd-anchored and lexically
208
+ normalised, in the platform's OWN separators (this value becomes
209
+ `data.launchJsonPath`, which the contract harness normalises itself).
210
+
211
+ `os.path.abspath`, not `Path.resolve()`: abspath is purely lexical, so a
212
+ project reached through a symlink keeps the name the user typed and a path
213
+ that does not exist yet still resolves -- `resolve()` would rewrite both.
214
+ """
215
+ return os.path.abspath(path)
216
+
217
+
218
+ def _resolve_project_reporting_fields(
219
+ project_arg: str, board_yaml_arg: str | None, sdk_root_arg: str | None
220
+ ) -> tuple[str, str, str | None, str, str | None]:
221
+ """`(project.root, project.boardYaml, sdk_root, sdk_source_tier,
222
+ foreign_global_default_for)`. The first two, both posix, are the fields
223
+ this command REPORTS; `sdk_root` is read for the alp-sdk#1026 metadata
224
+ fallback (`_fill_debug_probe_identity_from_sdk`) below and is NEVER
225
+ attached to the outgoing envelope.
226
+
227
+ The last two are the SDK's PROVENANCE, and they are returned for exactly
228
+ one consumer: [`_sdk_core_refusal_authority`], the tan-cli#477 review
229
+ round. Reading a debug-probe identity out of whatever SDK happened to
230
+ resolve is harmless -- the worst case is a placeholder that stays a
231
+ placeholder. REFUSING the run at exit 2 on that same checkout's word is
232
+ not, so the caller has to know which tier answered before it lets the
233
+ answer decide anything.
234
+
235
+ Delegates to the SAME shared resolver every other command uses
236
+ (`build_output.resolve_project_context`, port of
237
+ `util::resolve_cli_project_context`) instead of a hand-rolled duplicate of
238
+ its workspace/board_yaml computation -- tan-cli#170: `debug-config` used to
239
+ hardcode `board_yaml: None` on every path, even a success with a valid
240
+ `board.yaml` sitting in the resolved root, while every other command took
241
+ both from the shared resolver.
242
+
243
+ Deliberately the `_no_sdk_report` half of the Rust resolver's contract:
244
+ the shared resolver's `sdk` half IS read here (unlike before alp-sdk#1026),
245
+ but the caller must never pass it into the `Envelope(...)` call --
246
+ resolving one could only add an undeclared `sdk` envelope key as a side
247
+ effect of a field this command otherwise merely reports (tan-cli#111
248
+ follow-up), and `debug-config` still drives no SDK subprocess of any kind
249
+ (I-32).
250
+
251
+ `board.yaml`'s existence is NOT checked HERE, matching
252
+ `project.rs::resolve_board_yaml_path`, which joins the configured relative
253
+ path onto the workspace root unconditionally -- this function only names
254
+ where a `board.yaml` WOULD live. [`_run`]'s caller is the seam that checks
255
+ (tan-cli#236, `Project.resolved`): the four hermetic goldens run in a
256
+ scratch directory holding no `board.yaml` at all and report a `null`
257
+ `project.boardYaml`, not this joined path.
258
+ """
259
+ context = resolve_project_context(project_arg, board_yaml_arg, sdk_root_arg)
260
+ sdk_root = context.sdk.root if context.sdk is not None else None
261
+ return (
262
+ context.workspace_root,
263
+ context.board_yaml,
264
+ sdk_root,
265
+ context.sdk_source_tier,
266
+ context.foreign_global_default_for,
267
+ )
268
+
269
+
270
+ def _workspace_relative(workspace_root: str, path: str) -> str:
271
+ """Rewrite a path under `workspace_root` as `${workspaceFolder}/<rel>`, so a
272
+ committed launch.json stays portable; an artefact outside the project (an
273
+ out-of-tree build root) is left absolute rather than mangled.
274
+
275
+ The tail is SLICED out of `path`'s own text, never re-rendered through
276
+ `Path`. Rust's `strip_prefix` hands back a borrowed subslice, so the
277
+ separators the manifest itself used survive; `Path.relative_to(...)` builds a
278
+ fresh object that re-renders with the platform separator, which on Windows
279
+ turns a `/`-authored manifest path into `${workspaceFolder}/build\\...`. That
280
+ string is handed to a debug adapter verbatim, so the difference is visible.
281
+
282
+ Rust's `strip_prefix` folds case on the Windows drive PREFIX only (`c:` ==
283
+ `C:`) and compares every other component exactly; mirrored here. In practice
284
+ both values come from the same `abspath`, so it only matters against a
285
+ hand-written manifest.
286
+ """
287
+ root = workspace_root.replace("\\", "/").rstrip("/")
288
+ probe = path.replace("\\", "/")
289
+ if os.name == "nt":
290
+ root_drive, root_rest = os.path.splitdrive(root)
291
+ probe_drive, probe_rest = os.path.splitdrive(probe)
292
+ matched = root_drive.lower() == probe_drive.lower() and probe_rest.startswith(
293
+ f"{root_rest}/"
294
+ )
295
+ else:
296
+ matched = probe.startswith(f"{root}/")
297
+ if not matched:
298
+ return path
299
+ return "${workspaceFolder}/" + path[len(root) + 1 :]
300
+
301
+
302
+ def _load_yaml(path: Path) -> Any | None:
303
+ """Parse a YAML file this project's build wrote, or `None`.
304
+
305
+ `None` for every failure -- missing file, unreadable, non-UTF-8, malformed
306
+ YAML, or PyYAML not installed. Both callers are best-effort by contract (see
307
+ [`_resolve_from_build`]): the field simply stays unresolved and the draft
308
+ keeps the placeholder it has always had.
309
+
310
+ tan ships no YAML dependency of its own, so PyYAML is used when importable
311
+ -- it always is in a Zephyr workspace, which is the only place a
312
+ `system-manifest.yaml` can exist -- and its absence degrades to "nothing
313
+ resolved" rather than failing a command whose whole point is to work before
314
+ the first build.
315
+ """
316
+ try:
317
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
318
+ except ImportError:
319
+ return None
320
+ try:
321
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
322
+ except Exception: # noqa: BLE001 -- Zephyr's/the SDK's output, not ours
323
+ return None
324
+
325
+
326
+ def _select_slice(
327
+ slices: list[dict[str, Any]], target: str, core: str | None
328
+ ) -> dict[str, Any] | None:
329
+ """The manifest slice a debug draft resolves against, for a target/`--core`.
330
+
331
+ `native-host` is a special case: its runnable artefact is the project's
332
+ `native_sim` slice, found by BOARD target -- the same discriminator `tan run`
333
+ uses to pick the host binary -- not by `os`. A board that also builds a real
334
+ Zephyr MCU slice still has one or more slices with `os: zephyr`; the old
335
+ `os`-keyed match took the first of those, which on such a board is often the
336
+ MCU slice, pointing `Alp: Native Sim Debug` at a Cortex-M ELF that CodeLLDB
337
+ then cannot launch. `--core` is intentionally unused on this arm: a
338
+ `native_sim` slice's `core_id` is not a hardware core selector.
339
+ """
340
+ if target == NATIVE_HOST:
341
+ return next(
342
+ (s for s in slices if is_native_sim_board(s.get("board"))),
343
+ None,
344
+ )
345
+ manifest_os = MANIFEST_OS_BY_TARGET.get(target)
346
+ if manifest_os is None:
347
+ return None
348
+ # `--core` names the slice outright; otherwise the first slice of this
349
+ # target's OS wins, which is the whole manifest on a single-core project.
350
+ return next(
351
+ (
352
+ s
353
+ for s in slices
354
+ if s.get("os") == manifest_os and (core is None or s.get("core_id") == core)
355
+ ),
356
+ None,
357
+ )
358
+
359
+
360
+ def _slice_jlink_serial(slice_: dict[str, Any]) -> str | None:
361
+ """The slice's `flash_args.jlink_serial` -- the probe `tan flash` selects --
362
+ or `None` when absent or malformed (validated the way `tan flash` does, so
363
+ a value it would refuse is never written into launch.json)."""
364
+ try:
365
+ serial = fa_str_checked(slice_.get("flash_args"), "jlink_serial", False)
366
+ if serial is not None:
367
+ validate_identifier(serial, "jlink_serial")
368
+ except FlashPlanError:
369
+ return None
370
+ return serial
371
+
372
+
373
+ def _runner_arg_values(argv: Any, flag: str) -> list[str]:
374
+ """Every value a runner's argv gives for `flag`, in either form west emits:
375
+ `--device=Cortex-M55` (inline) or `--config <path>` (separate token).
376
+
377
+ A list, not a single value: OpenOCD takes more than one `--config` and
378
+ cortex-debug's `configFiles` is an array -- collapsing to the first would
379
+ silently drop a board's second config file and produce a session that
380
+ attaches to half a target.
381
+ """
382
+ if not isinstance(argv, list):
383
+ return []
384
+ tokens = [a for a in argv if isinstance(a, str)]
385
+ inline = f"{flag}="
386
+ values: list[str] = []
387
+ i = 0
388
+ while i < len(tokens):
389
+ arg = tokens[i]
390
+ if arg.startswith(inline):
391
+ value = arg[len(inline) :]
392
+ if value:
393
+ values.append(value)
394
+ elif arg == flag:
395
+ # The next token is the value only when it is not another flag -- a
396
+ # trailing `--config` with nothing after it must not swallow the
397
+ # following option as if it were a path.
398
+ if i + 1 < len(tokens) and not tokens[i + 1].startswith("-"):
399
+ values.append(tokens[i + 1])
400
+ i += 1
401
+ i += 1
402
+ return values
403
+
404
+
405
+ def _str_or_none(value: Any) -> str | None:
406
+ """A non-empty string, else `None`. An empty string in Zephyr's own output
407
+ means "absent", not a path (`runners.rs::non_empty`)."""
408
+ return value if isinstance(value, str) and value != "" else None
409
+
410
+
411
+ def _str_list(value: Any) -> list[str]:
412
+ return [v for v in value if isinstance(v, str)] if isinstance(value, list) else []
413
+
414
+
415
+ def _resolve_from_build(
416
+ workspace_root: str, target: str, server: str, core: str | None
417
+ ) -> tuple[LaunchResolution, list[str], str | None]:
418
+ """Everything this project's own build knows about how to debug it: the
419
+ per-core ELF from `build/system-manifest.yaml`, and the probe/tool paths from
420
+ that slice's `runners.yaml` -- the same file `west flash` reads.
421
+
422
+ Best-effort throughout. A missing manifest (pre-build), a missing slice, an
423
+ unreadable or reshaped `runners.yaml` each leave the corresponding field
424
+ unresolved instead of failing the command: `debug-config` must still emit its
425
+ draft before the first build. Returns the resolution, the runner ids the
426
+ board registered (for the "this build registers no such runner" note), and
427
+ the `core_id` of the slice this run actually selected (`None` before a
428
+ matching slice is found) -- the SAME id `--core` would have named
429
+ explicitly. alp-sdk#1026's SDK-metadata fallback
430
+ (`_fill_debug_probe_identity_from_sdk`) needs it to index `jlink_device`
431
+ (keyed per core) even when the caller passed no `--core` of its own, so a
432
+ single-core project's ALREADY-built slice still resolves without forcing
433
+ the user to repeat a core id `tan` already knows.
434
+ """
435
+ resolution = LaunchResolution()
436
+ manifest = _load_yaml(Path(workspace_root, "build", "system-manifest.yaml"))
437
+ slice_ = _select_slice(manifest_slices(manifest), target, core)
438
+ if slice_ is None:
439
+ return resolution, [], None
440
+ core_id = slice_.get("core_id")
441
+ core_id = core_id if isinstance(core_id, str) else None
442
+
443
+ artefact = _str_or_none(slice_.get("output_artefact"))
444
+ if artefact is not None:
445
+ # A host target needs the sibling swap; every other target kind
446
+ # genuinely wants the artefact verbatim.
447
+ if target == NATIVE_HOST:
448
+ artefact = native_sim_exe_beside(artefact)
449
+ resolution.executable = _workspace_relative(workspace_root, artefact)
450
+
451
+ if server == JLINK:
452
+ resolution.probe_serial = _slice_jlink_serial(slice_)
453
+
454
+ build_dir = _str_or_none(slice_.get("build_dir"))
455
+ if build_dir is None:
456
+ return resolution, [], core_id
457
+ runners = _load_yaml(Path(build_dir, "zephyr", "runners.yaml"))
458
+ if not isinstance(runners, dict):
459
+ return resolution, [], core_id
460
+ config = runners.get("config")
461
+ config = config if isinstance(config, dict) else {}
462
+ args = runners.get("args")
463
+ args = args if isinstance(args, dict) else {}
464
+
465
+ resolution.gdb_path = _str_or_none(config.get("gdb"))
466
+ runner = _RUNNER_ID.get(server)
467
+ if runner is not None:
468
+ if server == JLINK:
469
+ values = _runner_arg_values(args.get(runner), "--device")
470
+ resolution.device = values[0] if values else None
471
+ elif server == OPENOCD:
472
+ resolution.server_path = _str_or_none(config.get("openocd"))
473
+ resolution.search_dirs = _str_list(config.get("openocd_search"))
474
+ resolution.config_files = _runner_arg_values(args.get(runner), "--config")
475
+ elif server == PYOCD:
476
+ values = _runner_arg_values(args.get(runner), "--target")
477
+ resolution.target_id = values[0] if values else None
478
+ return resolution, _str_list(runners.get("runners")), core_id
479
+
480
+
481
+ def _sdk_variant_debug_block(
482
+ sdk_root: str, sku: str, *, warnings: list[str] | None = None,
483
+ skipped: list[str] | None = None,
484
+ ) -> dict[str, Any] | None:
485
+ """The resolved SoC-JSON `variants[].debug` block for `sku`'s SoM preset,
486
+ or `None` when any step of the walk fails to resolve one.
487
+
488
+ The metadata-layout walk itself is `build_output.read_sdk_som_and_soc` --
489
+ the ONE reader `tan size` also drives, not a second one that could
490
+ disagree (the exact drift alp-sdk#1026 itself is about).
491
+
492
+ A forward-only `resolve_variant` match: `sku` is passed as `None`,
493
+ deliberately disabling the reverse `sku in alp_module_skus` fallback
494
+ `tan size` itself relies on -- a drifted/`TBD` preset must resolve NO
495
+ identity rather than possibly a WRONG one that still connects a live debug
496
+ session to the wrong part (alp-sdk#1026 review finding #7).
497
+
498
+ *warnings* (tan-cli#964 review, major 5): threaded straight through to
499
+ `read_sdk_som_and_soc`, which threads it into both leaf readers'
500
+ `validate_document` calls -- the same `metadata_root`/`warnings`
501
+ convention `tan size` already uses. Before this, `tan debug-config`
502
+ called `read_sdk_som_and_soc` with no `warnings` at all, so it validated
503
+ NOTHING despite the PR body's own claim that it inherited #964's WARN
504
+ half "transitively" -- it did not, until this parameter existed.
505
+
506
+ *skipped* (tan-cli#964 review, major 6): same threading, for the
507
+ "skip-but-disclose" collector -- a note when the schema file itself is
508
+ simply absent, rather than the silent `[]` that used to be.
509
+ """
510
+ metadata_root = os.path.join(sdk_root, "metadata")
511
+ walked = read_sdk_som_and_soc(
512
+ metadata_root, sku, warnings=warnings, skipped=skipped, explain_unsupported=True
513
+ )
514
+ if walked is None:
515
+ return None
516
+ _silicon, silicon_variant, variants, _soc_flash_mb, _soc_cores = walked
517
+ variant = resolve_variant(silicon_variant, None, variants)
518
+ if variant is None:
519
+ return None
520
+ debug = variant.get("debug")
521
+ return debug if isinstance(debug, dict) else None
522
+
523
+
524
+ def _board_som_sku(board_yaml_path: str) -> str | None:
525
+ """This project's `board.yaml` `som.sku`, or `None` for every failure --
526
+ missing/unreadable file, no PyYAML, not a mapping, no `som.sku`, an empty
527
+ one. Extracted so the tan-cli#477 `--core` guard and
528
+ [`_fill_debug_probe_identity_from_sdk`] read the SAME field the same
529
+ tolerant way rather than each spelling the four-step `isinstance` walk out
530
+ again (they used to be one site; the guard needs it strictly earlier)."""
531
+ board = _load_yaml(Path(board_yaml_path))
532
+ som = board.get("som") if isinstance(board, dict) else None
533
+ sku = som.get("sku") if isinstance(som, dict) else None
534
+ return sku if isinstance(sku, str) and sku != "" else None
535
+
536
+
537
+ def _sdk_published_cores(
538
+ sdk_root: str | None, board_yaml_path: str, *, warnings: list[str] | None = None,
539
+ skipped: list[str] | None = None,
540
+ ) -> frozenset[str]:
541
+ """Every core id this project's SoM publishes, per the SDK -- the SoC
542
+ JSON's own `cores[].id`, unioned with the `variants[].debug.jlink_device`
543
+ keys. Empty whenever the walk resolves nothing, which the caller reads as
544
+ "cannot be asked", never as "this SoM has no cores".
545
+
546
+ *warnings*/*skipped*: see `_sdk_variant_debug_block`'s own doc -- same
547
+ threading, same reason (tan-cli#964 review, majors 5/6).
548
+
549
+ tan-cli#477 major 2. `--core` pre-build is NOT decoration: with no
550
+ `build/system-manifest.yaml` to check against, it selects which core's
551
+ SDK-published debug-probe identity to resolve (alp-sdk#1026), so a guard
552
+ keyed only on "does a build manifest exist" cannot tell a typo apart from
553
+ that legitimate use -- which is exactly why #508 left this half open. It
554
+ CAN be told apart by consulting the SDK's own published core list, and
555
+ that list is already reachable here from the same `--sdk-root` the
556
+ identity fallback below reads.
557
+
558
+ `cores[].id` is the authority; the `jlink_device` union only widens it for
559
+ a SoC JSON that omits `cores` entirely. Measured across every SoC JSON in
560
+ alp-sdk `metadata/socs/**` (alif e3-e8, deepx dx/m1,
561
+ renesas rzv2n/n44): the `jlink_device` keys are a SUBSET of `cores[].id`
562
+ in all of them, so on real metadata the union IS `cores[].id`. It is kept
563
+ because the frozen contract fixture
564
+ `contract/envelopes/debug-config-preview-zephyr-mcu-sdk-identity/sdk` --
565
+ which cannot be edited -- publishes `jlink_device` and no `cores` at all,
566
+ and its `--core m55_hp` must not become a false refusal.
567
+
568
+ Same vocabulary either side: a `build/system-manifest.yaml` slice's
569
+ `core_id` is spelled with these same ids (`m55_hp`, `m33_sm`,
570
+ `a55_cluster`), which is what lets this stand in for the manifest check
571
+ when there is no manifest.
572
+ """
573
+ if sdk_root is None:
574
+ return frozenset()
575
+ sku = _board_som_sku(board_yaml_path)
576
+ if sku is None:
577
+ return frozenset()
578
+ walked = read_sdk_som_and_soc(
579
+ os.path.join(sdk_root, "metadata"), sku, warnings=warnings, skipped=skipped,
580
+ explain_unsupported=True,
581
+ )
582
+ if walked is None:
583
+ return frozenset()
584
+ _silicon, silicon_variant, variants, _soc_flash_mb, soc_cores = walked
585
+ cores = {core_id for core_id, _tcm_kb in soc_cores}
586
+ variant = resolve_variant(silicon_variant, None, variants)
587
+ debug = variant.get("debug") if isinstance(variant, dict) else None
588
+ jlink_device = debug.get("jlink_device") if isinstance(debug, dict) else None
589
+ if isinstance(jlink_device, dict):
590
+ cores |= {k for k in jlink_device if isinstance(k, str)}
591
+ return frozenset(cores)
592
+
593
+
594
+ def _sdk_core_refusal_authority(
595
+ sdk_root: str | None, sdk_source_tier: str, foreign_global_default_for: str | None
596
+ ) -> str | None:
597
+ """The SDK root that is allowed to REFUSE a `--core`, or `None`.
598
+
599
+ tan-cli#477 major 2, REVIEW round. The refusal below is decided by an SDK
600
+ checkout, and until this guard existed that could be a checkout the user
601
+ never named: with no `--sdk-root` and no project pin, `resolve_sdk_tiered`
602
+ falls through to the machine-global default (`~/.alp/sdk-default`), which
603
+ `tan bootstrap` may have last pointed at an unrelated project. Measured on
604
+ this box, from a project directory that names no SDK at all:
605
+
606
+ resolve_project_context('.', None, None).sdk
607
+ -> SdkInfo(root='.../alp-workspace/sdk-triage',
608
+ source_tier='globalDefault',
609
+ foreign_global_default_for='.../t477/p')
610
+
611
+ and the refusal flips purely on which checkout answers -- measured, same
612
+ project, same `--core m55_hp`, two SDK roots differing only in `e8.json`'s
613
+ `cores[]`: `--sdk-root A` -> exit 0, `--sdk-root B` -> exit 2
614
+ `debug-config.core-unknown`. `debug-config` reports no `sdk` block at all
615
+ (deliberately -- see [`_resolve_project_reporting_fields`]) and does not
616
+ emit the `sdk.global-default-foreign-project` warning `size`/`image` emit
617
+ for this same situation, so a customer would have no way to see that a
618
+ stranger's checkout had just refused their build.
619
+
620
+ The guard's own rule -- "refuse only what you can PROVE unknown" -- settles
621
+ it. A global default last pinned by ANOTHER project cannot prove anything
622
+ about THIS project's SoM, so it declines to `None`, which the caller reads
623
+ as "cannot be asked" and stays silent, exactly as it does with no SDK at
624
+ all. That single flag is the whole discriminator on purpose: every other
625
+ way to arrive here -- `--sdk-root`, a working project pin, or a global
626
+ default THIS project's own bootstrap set (including after a broken pin fell
627
+ through to it) -- is an SDK this project is entitled to be judged by. Those
628
+ still refuse, and the refusal now names the checkout and the tier that
629
+ decided it.
630
+ """
631
+ if sdk_root is None:
632
+ return None
633
+ if foreign_global_default_for is not None:
634
+ return None
635
+ return sdk_root
636
+
637
+
638
+ def _sdk_core_unknown_message(
639
+ core: str, published: frozenset[str], sdk_root: str, sdk_source_tier: str
640
+ ) -> str:
641
+ """The tan-cli#477 counterpart of `debug_launch.explicit_core_unknown_
642
+ message`, which names the cores a BUILD produced. There is no build here,
643
+ so it names the cores the SDK publishes instead -- same refusal, same
644
+ code, a different (and the only available) authority.
645
+
646
+ It names WHICH checkout, and by which tier it was chosen (REVIEW round).
647
+ The build-manifest arm needs no such line -- the manifest is inside the
648
+ project the user pointed at -- but this arm's authority may be a checkout
649
+ the argv never mentions (`--sdk-root` absent, a project pin, or a global
650
+ default), and `debug-config` publishes no `sdk` envelope block for the
651
+ reader to look it up in. A refusal a customer cannot attribute is a support
652
+ ticket; naming the path makes `--sdk-root <the right one>` the obvious next
653
+ move. See [`_sdk_core_refusal_authority`] for the tier that is NOT allowed
654
+ to get this far."""
655
+ cores = ", ".join(sorted(published))
656
+ return (
657
+ f"--core {core} names no core this project's SoM has (its cores, per "
658
+ f"the SDK's published metadata for this board.yaml's som.sku: "
659
+ f"{cores}), and this project has no build/system-manifest.yaml to "
660
+ "check against instead. Pass a --core value this SoM actually has. "
661
+ f"That core list came from the alp-sdk checkout at {sdk_root} "
662
+ f"(resolved by: {sdk_source_tier}) -- if that is not the checkout this "
663
+ "project should be judged against, pass --sdk-root explicitly. "
664
+ "Left unrefused, this wrote a launch.json whose device stayed the "
665
+ "literal <resolved-device> placeholder at exit 0 -- a file that looks "
666
+ "valid and fails later in the debugger (tan-cli#477)."
667
+ )
668
+
669
+
670
+ def _fill_debug_probe_identity_from_sdk(
671
+ resolution: LaunchResolution,
672
+ sdk_root: str | None,
673
+ board_yaml_path: str,
674
+ core_id: str | None,
675
+ *,
676
+ warnings: list[str] | None = None,
677
+ skipped: list[str] | None = None,
678
+ ) -> tuple[bool, frozenset[str]]:
679
+ """alp-sdk#1026: fill `resolution`'s remaining `device`/`target_id`/
680
+ `config_files` gaps from the SDK's published per-variant debug-probe
681
+ identity (`variants[].debug`, alp-sdk#987), so `tan debug-config` resolves
682
+ a real J-Link device / pyOCD target before the project has ever been built
683
+ -- the case [`_resolve_from_build`]'s `runners.yaml` read structurally
684
+ cannot cover. Port of
685
+ `crates/tan-cli/src/commands/debug_config.rs::fill_debug_probe_identity_from_sdk`.
686
+
687
+ Best-effort throughout, exactly like [`_resolve_from_build`]: a missing
688
+ `board.yaml`/`som.sku`, no resolved SDK root, a missing/unreadable SoM
689
+ preset or SoC-JSON file, or a SoC variant that resolves but declares no
690
+ `debug` block each leave `resolution` exactly as it was -- the caller's
691
+ existing placeholder note still applies, and nothing here can fail the
692
+ command.
693
+
694
+ Returns `(debug_block_found, known_jlink_cores)`. `debug_block_found` is
695
+ whether a `variants[].debug` block was actually found for the resolved SoC
696
+ variant -- distinct from whether every field this run wanted got filled
697
+ from it. The caller uses this (alp-sdk#1026 review finding #4) to tell
698
+ "the SDK publishes an identity for this part, but not a value for the
699
+ specific field this server needs yet" apart from "no identity was
700
+ resolvable at all". `known_jlink_cores` is `jlink_device`'s own key set --
701
+ tan-cli#489 (4): `jlink_device` is the ONE field here keyed by core
702
+ (`pyocd_target`/`openocd_config` are not), so a `device` placeholder that
703
+ survives despite `debug_block_found` being true is either "no core id was
704
+ resolved to index with" or "the given core id has no entry in this SoM's
705
+ published map" -- two distinct, both-fixable-by-`--core` causes the caller
706
+ cannot tell apart, or name the known cores for, without this set.
707
+
708
+ *warnings*/*skipped*: see `_sdk_variant_debug_block`'s own doc -- same
709
+ threading, same reason (tan-cli#964 review, majors 5/6).
710
+ """
711
+ if sdk_root is None:
712
+ return False, frozenset()
713
+ sku = _board_som_sku(board_yaml_path)
714
+ if sku is None:
715
+ return False, frozenset()
716
+ debug = _sdk_variant_debug_block(sdk_root, sku, warnings=warnings, skipped=skipped)
717
+ if debug is None:
718
+ return False, frozenset()
719
+ jlink_device = debug.get("jlink_device")
720
+ jlink_device = (
721
+ {k: v for k, v in jlink_device.items() if isinstance(v, str)}
722
+ if isinstance(jlink_device, dict)
723
+ else {}
724
+ )
725
+ pyocd_target = debug.get("pyocd_target")
726
+ pyocd_target = pyocd_target if isinstance(pyocd_target, str) else None
727
+ openocd_config = debug.get("openocd_config")
728
+ openocd_config = openocd_config if isinstance(openocd_config, str) else None
729
+ fill_debug_probe_identity_gaps(resolution, core_id, jlink_device, pyocd_target, openocd_config)
730
+ return True, frozenset(jlink_device.keys())
731
+
732
+
733
+ def _resolve_user_svd(workspace_root: str, arg: str) -> str:
734
+ """Resolve `--svd` into the value the launch configuration should carry.
735
+
736
+ **Anchor: the current directory, not the project root.** `--svd` is a
737
+ per-invocation flag typed at a shell prompt, so a relative path means what
738
+ the shell means by it. (A board-level `debug.svd` key, should one ever be
739
+ added, travels with the project and must anchor on the project root instead
740
+ -- the two have different lifetimes, so they get different anchors
741
+ deliberately rather than by omission.) The emitted string then goes through
742
+ the same [`_workspace_relative`] rewrite as `executable`: inside the project
743
+ it becomes `${workspaceFolder}/...` so a committed launch.json stays
744
+ portable, outside it stays absolute -- the normal case, since a vendor SVD
745
+ lives in the vendor SDK the user installed.
746
+
747
+ **A bad path is a HARD ERROR, never a silent drop back to "no SVD".**
748
+ tan-cli#67 established that cortex-debug fails the whole session on an
749
+ `svdFile` it cannot read, which is why the *unresolved* case drops the key.
750
+ But the user explicitly named this file: falling back would make a typo
751
+ indistinguishable from not passing the flag, and the failure would surface as
752
+ an unexplained empty peripheral view.
753
+ """
754
+ if arg.strip() == "":
755
+ raise DebugConfigError("Alp: --svd was given an empty path.")
756
+ # `abspath` on an absolute `arg` returns it normalised, so this handles both.
757
+ candidate = _normalise(arg)
758
+ # `stat` then S_ISREG, mirroring the Rust's `metadata()`-then-`is_file()`
759
+ # pair: the two failures get DIFFERENT messages, and a bare `is_file()`
760
+ # (false for both a missing path and a directory) could not tell them apart.
761
+ try:
762
+ mode = os.stat(candidate).st_mode
763
+ except OSError as err:
764
+ raise DebugConfigError(
765
+ f"Alp: --svd path cannot be read: {candidate} ({err}). "
766
+ "Pass the path to the vendor's own .svd file; the SDK ships none "
767
+ "(alp-sdk#948)."
768
+ ) from err
769
+ if not stat.S_ISREG(mode):
770
+ raise DebugConfigError(f"Alp: --svd path is not a file: {candidate}")
771
+ return _workspace_relative(workspace_root, candidate)
772
+
773
+
774
+ def _resolve_gdbserver_address(arg: str) -> str:
775
+ """Validate `--gdbserver-address` (tan-cli#321). Emitted verbatim into
776
+ `miDebuggerServerAddress` -- cppdbg accepts a bare hostname, an IPv4 or
777
+ bracketed-IPv6 literal, so there is no single `host:port` shape narrow
778
+ enough to validate without rejecting a real one; the only input that can
779
+ never be a real address is an empty string, the same floor `--svd` holds
780
+ for its own path argument.
781
+ """
782
+ if arg.strip() == "":
783
+ raise DebugConfigError("Alp: --gdbserver-address was given an empty value.")
784
+ return arg
785
+
786
+
787
+ def _gdbserver_address_unresolved_issue() -> Issue:
788
+ """tan-cli#321 direction 1: the yocto-userspace draft's
789
+ `miDebuggerServerAddress` is still the unresolved `<host>:<port>`
790
+ placeholder in what this run actually produced. Severity `info` -- this is
791
+ not a failure, it is the one field on this target class that NO build and
792
+ NO SDK-published metadata can ever resolve (it names where the board ends
793
+ up after deploy, a fact that exists only at runtime), so surfacing it
794
+ explicitly is the whole point of this issue rather than leaving F5 to fail
795
+ silently at connect.
796
+
797
+ tan-cli#138 vs #321: unlike the other three target classes, this profile's
798
+ `preLaunchTask` carries NO restored default -- see
799
+ `tan.core.debug_launch.DEFAULT_PRE_LAUNCH_TASK`'s own doc comment for why:
800
+ alp-sdk-vscode registers no working task for yocto-userspace (the only one
801
+ that exists exits 1 by design), so naming one here would put the
802
+ "preLaunchTask terminated with exit code 1" dialog in front of every F5.
803
+ Said here, alongside the address gap, rather than as a second issue: both
804
+ point at the same manual deploy-and-start-gdbserver step, and a customer
805
+ who wants a reminder can still opt one in explicitly.
806
+ """
807
+ return Issue(
808
+ "debug-config.gdbserver-address-unresolved",
809
+ "info",
810
+ "This yocto-userspace configuration's `miDebuggerServerAddress` is "
811
+ "still the placeholder `<host>:<port>` -- the host and gdbserver port "
812
+ "are a runtime property of the deployed board that no build can "
813
+ "resolve. Fill it in by hand in launch.json once you know it, or pass "
814
+ "`--gdbserver-address host:port` next time you regenerate this "
815
+ "profile. tan has no deploy mechanism of its own, so deploying the "
816
+ "binary and starting gdbserver on the target before F5 is still a "
817
+ "manual step; this profile carries no `preLaunchTask` reminder of "
818
+ "that by default (tan-cli#138 vs #321 -- the extension's only "
819
+ "registered task for this target exits 1 by design, so naming it "
820
+ "would fail before every F5). Pass `--pre-launch-task '<name>'` to "
821
+ "add a reminder of your own.",
822
+ )
823
+
824
+
825
+ def _has_placeholder(value: Any) -> bool:
826
+ """Whether any `<...>` placeholder survived resolution, anywhere in the draft
827
+ -- including inside `configFiles`, which is an array.
828
+
829
+ The string test is [`is_unresolved_placeholder`], the SAME predicate the
830
+ launch.json merge uses, so "keep the still-needs-resolution note" and "do not
831
+ overwrite this hand-filled value" can never disagree. It used to be
832
+ `contains("<resolved-")`, which called the two-token `<host>:<port>` a real
833
+ address: a yocto config whose `<resolved-gdb>` resolved then dropped the note
834
+ while `miDebuggerServerAddress` was still unusable.
835
+ """
836
+ if isinstance(value, str):
837
+ return is_unresolved_placeholder(value)
838
+ if isinstance(value, list):
839
+ return any(_has_placeholder(v) for v in value)
840
+ if isinstance(value, dict):
841
+ return any(_has_placeholder(v) for v in value.values())
842
+ return False
843
+
844
+
845
+ # ---------------------------------------------------------------------------
846
+ # tan-cli#1179: the OpenOCD-without-host-tools note.
847
+ # tan-cli#1194 review: the note's WORDING, split three ways so each spelling
848
+ # is true in the state it is emitted in.
849
+ #
850
+ # THE SILENT SHAPE THIS CLOSES. Zephyr writes `config.openocd` /
851
+ # `config.openocd_search` into `runners.yaml` only inside `if(OPENOCD)`
852
+ # (`cmake/flash/CMakeLists.txt`), and `OPENOCD` is a bare, optional
853
+ # `find_program(OPENOCD openocd)` (`cmake/modules/FindHostTools.cmake`, whose
854
+ # own comment reads "openocd is an optional dependency") that normally finds
855
+ # the SDK's copy through `list(APPEND CMAKE_PREFIX_PATH ${HOST_TOOLS_HOME}/usr)`
856
+ # in the SDK's own `cmake/zephyr/host-tools.cmake`. Since tan-cli#1176/#1178
857
+ # `tan bootstrap` passes `--no-hosttools`, so there is no `hosttools/` to find.
858
+ # CMake's `if()` reads `OPENOCD-NOTFOUND` as false, so both keys are ABSENT
859
+ # rather than present-and-empty; both `serverpath` and `searchDir` are ADDITIVE
860
+ # keys in `apply_launch_resolution`, and neither appears as a `<resolved-...>`
861
+ # placeholder in the OpenOCD draft, so `_has_placeholder` never sees them and
862
+ # the "Placeholder fields" note never fires. Contrast `svdFile`/`svdPath`,
863
+ # which ARE placeholders and ARE deleted -- that is the path that would have
864
+ # warned. `NO_HOSTTOOLS_FLAG` in `tan.core.toolchain_provision` is where the
865
+ # flag itself is decided, and records the same degradation from that side.
866
+ #
867
+ # A NOTE, NOT A FAILURE. The emitted configuration is valid and `configFiles`
868
+ # still resolves (from `board.cmake`, not from `hosttools/`). What is missing
869
+ # is one advisory field, so this rides in `data.notes` and `tan debug-config`
870
+ # still exits 0 with `ok: true`.
871
+ #
872
+ # THREE CONDITIONS, ALL LOAD-BEARING -- see `doctor_cmd.west_check`'s own
873
+ # account of the lesson ("a warning that fires on every correct install trains
874
+ # users to ignore warnings"):
875
+ #
876
+ # * `server` AND the draft's `servertype` are both OpenOCD. `server` is what
877
+ # the caller asked for; `servertype` is what the FINAL draft emits, and a
878
+ # yocto-userspace (cppdbg) draft has no `serverpath` field to be missing in
879
+ # the first place -- warning there would be noise about a key that target
880
+ # kind never carries.
881
+ # * `serverpath` is absent from the draft. That is the resolution's own
882
+ # answer, read the same way the rest of `_preview_notes_for` reads the final
883
+ # draft: `apply_launch_resolution` inserts the key if and only if
884
+ # `runners.yaml` gave it one.
885
+ # * No `openocd` on PATH. This is what keeps a correctly-provisioned host
886
+ # quiet, including the MIXED host measured in tan-cli#1179 (system OpenOCD
887
+ # on PATH, no `hosttools/`), which is silenced twice over: with
888
+ # `find_program` succeeding, `runners.yaml` DOES carry `config.openocd` --
889
+ # alongside an `openocd_search` pointing at the SDK's nonexistent
890
+ # `share/openocd/scripts`, from `set_ifndef(OPENOCD_DEFAULT_PATH ...)`,
891
+ # measured benign because OpenOCD still loads off its built-in scripts
892
+ # directory -- so `serverpath` is present AND the probe finds one. A host
893
+ # that has OpenOCD but built before installing it stays quiet too:
894
+ # cortex-debug's own PATH lookup will find it, so there is nothing to tell
895
+ # that developer.
896
+ #
897
+ # WHICH WORDING (tan-cli#1194 review). The first spelling of this note
898
+ # asserted "this project's runners.yaml has no 'config.openocd' key" in EVERY
899
+ # state it fired in, including the state it fires in most often: before the
900
+ # first build, where `_resolve_from_build` returns at its missing-manifest
901
+ # branch and never opens a `runners.yaml` at all. That is the command's own
902
+ # primary documented use ("`debug-config` must still emit its draft before
903
+ # the first build", `_resolve_from_build`'s docstring), and it is the state the
904
+ # `debug-config-preview-baremetal-mcu` contract golden records -- which
905
+ # alp-sdk-vscode surfaces to the user VERBATIM. So:
906
+ #
907
+ # * `registered_runners` is the "was a build read" signal, and it is the right
908
+ # one because `_resolve_from_build` returns `[]` from every branch that
909
+ # opened no readable `runners.yaml`: no manifest / no matching slice, a
910
+ # slice with no `build_dir`, and a `runners.yaml` that is missing or not a
911
+ # mapping. It over-approximates by exactly one contrived shape -- a
912
+ # `runners.yaml` that parses as a mapping but carries no `runners:` list --
913
+ # which is not a document any Zephyr build writes, and which by definition
914
+ # also carries no `config.openocd`, so both spellings describe a build that
915
+ # recorded nothing either way.
916
+ # * `baremetal-mcu` gets NEITHER Zephyr spelling. It is a plain-CMake, no-OS
917
+ # backend (`os: baremetal` -> `baremetal_cmake_flash`,
918
+ # `BAREMETAL_PROJECT_INCLUDE`), so no Zephyr SDK, no `find_program(OPENOCD
919
+ # openocd)` and no `--no-hosttools` install is in its story at all, and
920
+ # `_resolve_from_build` only ever reads `<build_dir>/zephyr/runners.yaml` --
921
+ # which that backend does not write. Handing that target kind a paragraph
922
+ # about the Zephyr SDK's host tools sends the reader to fix a thing that is
923
+ # not their problem. It still gets the actionable half (install `openocd`,
924
+ # put it on PATH, cortex-debug will find it), because a host with no
925
+ # `openocd` genuinely cannot start this session.
926
+ # * Only `zephyr-mcu` and `baremetal-mcu` can reach here at all:
927
+ # `SERVERS_BY_TARGET` offers `openocd` to those two and no others, and the
928
+ # `servertype` condition above already excludes every non-cortex-debug
929
+ # draft.
930
+ # ---------------------------------------------------------------------------
931
+ #: The half both Zephyr spellings end with: what to do about it. Factored out
932
+ #: so the advice has exactly one spelling and cannot drift between the two.
933
+ _OPENOCD_ZEPHYR_ADVICE = (
934
+ "tan bootstrap installs the Zephyr SDK with --no-hosttools, so the SDK "
935
+ "ships no OpenOCD of its own and Zephyr's optional find_program(OPENOCD "
936
+ "openocd) has none to record -- OpenOCD has to come from the system on "
937
+ "this host. Install it (your package manager's 'openocd', or a vendor "
938
+ "build), put it on PATH, and build this project so tan can fill "
939
+ "'serverpath' in; PATH alone is already enough for a session, because "
940
+ "cortex-debug falls back to its own PATH lookup when 'serverpath' is "
941
+ "absent."
942
+ )
943
+
944
+ #: A BUILD WAS READ and its `runners.yaml` carries no `config.openocd`. Kept
945
+ #: at module scope so the emitter and the tests that pin it have exactly one
946
+ #: spelling.
947
+ OPENOCD_NO_HOSTTOOLS_NOTE = (
948
+ "No OpenOCD executable could be resolved for this build, so this profile "
949
+ "carries no 'serverpath' (and no 'searchDir'): this project's runners.yaml "
950
+ "has no 'config.openocd' key, and no 'openocd' is on PATH here either. "
951
+ + _OPENOCD_ZEPHYR_ADVICE
952
+ + " Nothing else about this configuration is missing -- 'configFiles' "
953
+ "resolves from board.cmake, not from the SDK's host tools."
954
+ )
955
+
956
+ #: NO BUILD WAS READ -- the pre-build state, and the one the contract golden
957
+ #: records. Says there is no `runners.yaml` to have read a key out of, rather
958
+ #: than asserting something about a file that does not exist. The closing
959
+ #: sentence differs on purpose: pre-build EVERYTHING is still a placeholder,
960
+ #: so "nothing else is missing" would be false here.
961
+ OPENOCD_NO_HOSTTOOLS_NOTE_PRE_BUILD = (
962
+ "No OpenOCD executable could be resolved for this draft, so it carries no "
963
+ "'serverpath' (and no 'searchDir'): this project has no build output for "
964
+ "tan to read a runners.yaml out of yet, and no 'openocd' is on PATH here "
965
+ "either. "
966
+ + _OPENOCD_ZEPHYR_ADVICE
967
+ + " The rest of this draft is unresolved for the ordinary pre-build "
968
+ "reason the placeholder note above gives, not for want of host tools."
969
+ )
970
+
971
+ #: `baremetal-mcu`: no Zephyr, so no Zephyr-SDK paragraph. The actionable half
972
+ #: only, plus the real reason `serverpath` is absent on this target kind.
973
+ OPENOCD_NO_SERVERPATH_NOTE_BAREMETAL = (
974
+ "No OpenOCD executable could be resolved for this draft, so it carries no "
975
+ "'serverpath' (and no 'searchDir'), and no 'openocd' is on PATH here "
976
+ "either. This is a baremetal target, built by tan's plain-CMake backend "
977
+ "rather than by Zephyr, and tan fills 'serverpath' in only from a Zephyr "
978
+ "build's runners.yaml -- so nothing here recorded an OpenOCD path and it "
979
+ "has to come from the system. Install it (your package manager's "
980
+ "'openocd', or a vendor build) and put it on PATH; that alone is enough "
981
+ "for a session, because cortex-debug falls back to its own PATH lookup "
982
+ "when 'serverpath' is absent."
983
+ )
984
+
985
+
986
+ def _openocd_hosttools_note(
987
+ draft: dict[str, Any], server: str, registered_runners: list[str], target: str
988
+ ) -> str | None:
989
+ """The OpenOCD-without-a-`serverpath` note for the state this run is
990
+ actually in, or `None` when OpenOCD was not asked for, the resolution
991
+ produced a `serverpath`, or an `openocd` is on PATH. Which of the three
992
+ spellings, and why the wording is split at all, is in the block comment
993
+ above."""
994
+ if server != OPENOCD or draft.get("servertype") != OPENOCD:
995
+ return None
996
+ if "serverpath" in draft:
997
+ return None
998
+ if _first_on_path(_RUNTIME_EXECUTABLES[OPENOCD]) is not None:
999
+ return None
1000
+ if target == BAREMETAL_MCU:
1001
+ return OPENOCD_NO_SERVERPATH_NOTE_BAREMETAL
1002
+ if not registered_runners:
1003
+ return OPENOCD_NO_HOSTTOOLS_NOTE_PRE_BUILD
1004
+ return OPENOCD_NO_HOSTTOOLS_NOTE
1005
+
1006
+
1007
+ def _preview_notes_for(
1008
+ draft: dict[str, Any], registered_runners: list[str], server: str, target: str
1009
+ ) -> list[str]:
1010
+ """The preview notes, minus the "still needs resolution" warning once nothing
1011
+ is left to resolve. Keyed off the FINAL draft rather than off "did anything
1012
+ resolve": a partly-resolved config (a board that registers no OpenOCD runner
1013
+ still has `<resolved-openocd-board-cfg>`) must keep the warning, and a fully
1014
+ resolved one must lose it -- otherwise the note is noise on configs that are
1015
+ fine and silence on configs that are not.
1016
+ """
1017
+ notes = [
1018
+ n
1019
+ for n in launch_preview_notes()
1020
+ if not n.startswith("Placeholder fields") or _has_placeholder(draft)
1021
+ ]
1022
+ # The most common reason a placeholder survives: the board never registered
1023
+ # this server. Say so, instead of leaving the user to wonder which
1024
+ # project-specific value they are supposed to invent.
1025
+ runner = _RUNNER_ID.get(server)
1026
+ if runner is not None and registered_runners and runner not in registered_runners:
1027
+ # `{:?}` on a Vec<String> renders `["jlink", "openocd"]` -- reproduced
1028
+ # exactly, because this string is the note a customer reads and diffs
1029
+ # against the Rust binary's own output.
1030
+ rendered = "[" + ", ".join(f'"{r}"' for r in registered_runners) + "]"
1031
+ notes.append(
1032
+ f"This build registers no '{runner}' runner (runners.yaml: {rendered}), "
1033
+ "so its fields could not be resolved."
1034
+ )
1035
+ hosttools_note = _openocd_hosttools_note(draft, server, registered_runners, target)
1036
+ if hosttools_note is not None:
1037
+ notes.append(hosttools_note)
1038
+ return notes
1039
+
1040
+
1041
+ def _migrated_issue(from_name: str, to_name: str) -> Issue:
1042
+ """The #133 migration report: a pre-#155 `"ALP: ..."` entry was found in
1043
+ place of the current `"Alp: ..."` name and adopted onto it. Severity `info`,
1044
+ not `warning` or `error` -- nothing failed and no action is required; this
1045
+ exists so a consumer (or a customer reading `--format json`) can tell WHY the
1046
+ file changed under them instead of only by diffing it."""
1047
+ return Issue(
1048
+ "debug-config.legacy-entry-migrated",
1049
+ "info",
1050
+ f'Migrated the legacy launch-configuration entry "{from_name}" into '
1051
+ f'"{to_name}". Any value you had hand-filled in on the old entry for an '
1052
+ "unresolved-placeholder field (device, miDebuggerServerAddress, "
1053
+ "configFiles, …) carried across; every other field tan owns was "
1054
+ "refreshed to this run's values, same as an ordinary re-run. The old "
1055
+ "entry is gone.",
1056
+ )
1057
+
1058
+
1059
+ def _legacy_untouched_issue(legacy_name: str) -> Issue:
1060
+ """tan-cli#179: the ORDINARY same-name merge ran and a legacy `"ALP: ..."`
1061
+ counterpart of the SAME draft ALSO still sits in the file. Distinct from
1062
+ [`_migrated_issue`], which fires on the MISS path where the legacy entry is
1063
+ the one adopted -- here NEITHER entry was touched beyond the ordinary merge,
1064
+ so the customer's real hand-filled values may still be stranded on the
1065
+ leftover entry with nothing pointing at it."""
1066
+ return Issue(
1067
+ "debug-config.legacy-entry-untouched",
1068
+ "info",
1069
+ f'A leftover legacy launch-configuration entry "{legacy_name}" still '
1070
+ "sits in .vscode/launch.json alongside the entry this run updated. It "
1071
+ "was left untouched — nothing decides which of the two you may have "
1072
+ "hand-edited is authoritative — so if you filled in real values on "
1073
+ "the legacy entry, copy them onto the maintained one and remove the "
1074
+ "legacy entry yourself.",
1075
+ )
1076
+
1077
+
1078
+ def _sdk_identity_key_absent_issue(field: str) -> Issue:
1079
+ """alp-sdk#1026 review finding #4: emitted when the SDK DID publish a
1080
+ debug-probe identity for this project's SoC variant, but that identity
1081
+ does not (yet) include a value for `field` -- distinct from, and more
1082
+ specific than, the generic "Placeholder fields..." note every unresolved
1083
+ field already gets regardless of why. Severity `info`: this is the
1084
+ schema's own documented stance that an unpopulated key is a published
1085
+ "unknown", not an error and not a bug."""
1086
+ return Issue(
1087
+ "debug-config.sdk-identity-key-absent",
1088
+ "info",
1089
+ f"This SoM's SDK-published debug-probe identity (alp-sdk#987) does not "
1090
+ f"include a value for `{field}` yet, so it stays the placeholder shown "
1091
+ "in `configuration` — an unpopulated key is the correct published "
1092
+ '"unknown" (alp-sdk#1026), never a guess.',
1093
+ )
1094
+
1095
+
1096
+ def _sdk_identity_core_unresolved_issue(core: str | None, known_cores: frozenset[str]) -> Issue:
1097
+ """tan-cli#489 (4): distinct from [`_sdk_identity_key_absent_issue`] above,
1098
+ which is only correct when the field genuinely has no core dependency
1099
+ (`configFiles`/`targetId`) or a core WAS resolved and is simply not the
1100
+ one the SDK's map happens to key `device` under. On a never-built project
1101
+ with no `--core`, `jlink_device.get(None)` is structurally always `None`
1102
+ -- the SDK's map is never even consulted -- so `sdk-identity-key-absent`
1103
+ told the customer "the SDK publishes no `device` for this SoM" when
1104
+ `metadata/socs/.../*.json` may publish one for every core it has. Both
1105
+ "no core id was resolved" and "the given core id has no entry in the
1106
+ published map" share the SAME working remedy (pass `--core <id>` naming a
1107
+ core the SDK DOES publish), so this one code covers both; the message
1108
+ differs only in whether a core was named to look up."""
1109
+ if core is None:
1110
+ return Issue(
1111
+ "debug-config.sdk-identity-core-unresolved",
1112
+ "info",
1113
+ "This SoM's SDK-published debug-probe identity (alp-sdk#987) is "
1114
+ "keyed per core, and no core id was resolved for this run (no "
1115
+ "--core given, and this project has no prior build to infer one "
1116
+ "from) — so `device` stays the placeholder shown in "
1117
+ "`configuration`, even though the SDK may publish a value for "
1118
+ "one or more of this SoM's cores. Pass --core <id> to resolve it.",
1119
+ )
1120
+ cores = ", ".join(sorted(known_cores)) if known_cores else "none"
1121
+ return Issue(
1122
+ "debug-config.sdk-identity-core-unresolved",
1123
+ "info",
1124
+ f"This SoM's SDK-published debug-probe identity (alp-sdk#987) has no "
1125
+ f"`device` entry for core '{core}' — its published cores are: "
1126
+ f"{cores}. `device` stays the placeholder shown in `configuration`; "
1127
+ "pass --core with one of the cores above, if that is the core you "
1128
+ "meant.",
1129
+ )
1130
+
1131
+
1132
+ def _sdk_identity_overwrite_issue(field: str, existing_value: str, incoming_value: str) -> Issue:
1133
+ """alp-sdk#1026 review finding #1: emitted whenever the SDK's published
1134
+ debug-probe identity (not a real build) just replaced a concrete existing
1135
+ value on the entry this run wrote. Severity `info`, same reasoning as its
1136
+ three siblings above: the overwrite itself is not new or wrong (a value
1137
+ resolved from a real build already overwrote unconditionally, by design --
1138
+ see `tan.core.debug_launch._merge_configuration`'s doc comment) but a tool
1139
+ that replaces a customer's own value at `exit 0` with `issues: []` has
1140
+ told them nothing happened."""
1141
+ return Issue(
1142
+ "debug-config.sdk-identity-overwrite",
1143
+ "info",
1144
+ f'This write replaced the existing `{field}` value "{existing_value}" with '
1145
+ f'"{incoming_value}", resolved from the SDK\'s published debug-probe identity '
1146
+ "(alp-sdk#987) rather than from a real build. If "
1147
+ f'"{existing_value}" was a value you filled in on purpose — e.g. a J-Link '
1148
+ "flash-unlock device profile more specific than the generic attach device "
1149
+ "the SDK publishes — restore it in .vscode/launch.json; a value tan itself "
1150
+ "resolves from a real build will overwrite it again the same way.",
1151
+ )
1152
+
1153
+
1154
+ def _sdk_identity_appended_issue(field: str, existing_value: str, incoming_value: str) -> Issue:
1155
+ """tan-cli#982 review finding #2: the accepted degradation
1156
+ [`sdk_identity_stranded_appends`] exists to name -- an existing `field`
1157
+ entry `provenance` could not prove was tan's own prior output was left in
1158
+ place, and the value resolved this run from the SDK's published
1159
+ debug-probe identity (alp-sdk#987) was APPENDED beside it rather than
1160
+ replacing it. Severity `info`, same family as
1161
+ `debug-config.comments-dropped` / `debug-config.legacy-entry-untouched`:
1162
+ nothing failed, but a customer never told two `configFiles` entries now
1163
+ sit on the same launch configuration has no way to know one is stale --
1164
+ OpenOCD sources every `-f`, so two board configs on one TAP fail the
1165
+ debug session outright, the same failure class this write just created
1166
+ silently."""
1167
+ return Issue(
1168
+ "debug-config.sdk-identity-appended",
1169
+ "info",
1170
+ f'This write left the existing `{field}` value "{existing_value}" in place '
1171
+ f'and appended "{incoming_value}", resolved from the SDK\'s published '
1172
+ "debug-probe identity (alp-sdk#987), instead of replacing it -- tan could "
1173
+ f'not prove "{existing_value}" was its own prior output (no recorded '
1174
+ "`.alp/` provenance for it), so it left it rather than risk overwriting a "
1175
+ "value you filled in by hand. If it is stale, delete it from "
1176
+ ".vscode/launch.json yourself.",
1177
+ )
1178
+
1179
+
1180
+ def _load_files_preserved_issue(existing_value: str, incoming_value: str) -> Issue:
1181
+ """tan-cli#1020 review: the ``loadFiles`` sibling of
1182
+ ``debug-config.sdk-identity-appended`` above -- ``loadFiles`` names ONE
1183
+ deliberate artefact list per configuration, not a set of independently
1184
+ owned entries, so its own merge rule (``debug_launch._merge_load_files``)
1185
+ never appends beside an unproven existing value the way
1186
+ `configFiles`/`setupCommands` do; it leaves the existing value untouched,
1187
+ wholesale. Severity ``info``, same family as its siblings: nothing
1188
+ failed, but a tool that silently keeps a customer's value at `exit 0`
1189
+ with `issues: []` -- including a customer's own deliberate attach-only
1190
+ ``[]`` -- has told them nothing about the divergence between what is on
1191
+ disk and what this run would otherwise have written."""
1192
+ return Issue(
1193
+ "debug-config.load-files-preserved",
1194
+ "info",
1195
+ f'This write left the existing `loadFiles` value "{existing_value}" in place '
1196
+ f'instead of replacing it with "{incoming_value}", resolved from this run\'s '
1197
+ "own draft -- tan could not prove the existing value was its own prior output "
1198
+ "(no recorded `.alp/` provenance for it), so it left it rather than risk "
1199
+ "overwriting a value that may have been filled in by hand, including an "
1200
+ "explicit `[]` for an attach-only session. If it is stale, update it in "
1201
+ ".vscode/launch.json yourself.",
1202
+ )
1203
+
1204
+
1205
+ def _comments_dropped_issue() -> Issue:
1206
+ """tan-cli#182 review finding #2: this write dropped a comment (or trailing
1207
+ comma) sitting inside a span it rewrote. Severity `info` -- nothing failed
1208
+ and there is no action to take, but a tool that discarded user-authored
1209
+ content must never report unqualified success (#182's own floor)."""
1210
+ return Issue(
1211
+ "debug-config.comments-dropped",
1212
+ "info",
1213
+ "This write dropped a comment (or trailing comma) that sat inside the "
1214
+ "part of .vscode/launch.json it rewrote — either inside the one entry "
1215
+ "being updated, or, if the file's shape couldn't be confidently "
1216
+ "spliced, anywhere in the file. Everything outside that span is "
1217
+ "untouched.",
1218
+ )
1219
+
1220
+
1221
+ def _data(
1222
+ *,
1223
+ generated_at: str,
1224
+ target: str,
1225
+ server: str,
1226
+ preview: bool,
1227
+ launch_json_path: str,
1228
+ replaced: bool,
1229
+ notes: list[str],
1230
+ configuration: Any,
1231
+ ) -> dict[str, Any]:
1232
+ return {
1233
+ "schemaVersion": DATA_SCHEMA_VERSION,
1234
+ "generatedAt": generated_at,
1235
+ "targetKind": target,
1236
+ "server": server,
1237
+ "preview": preview,
1238
+ "launchJsonPath": launch_json_path,
1239
+ "replaced": replaced,
1240
+ # tan-cli#945: producer-stated, so a consumer never has to re-derive
1241
+ # "does starting this profile write real hardware" from cortex-debug's
1242
+ # own adapter schema (its `loadFiles` default, or one of thirteen
1243
+ # `*Commands` lists that could carry a bare `load`) the way
1244
+ # alp-sdk-vscode#586 had to before its consent dialog could even ask
1245
+ # the question. Derived from `target` alone (`programs_device`), so it
1246
+ # is present on every outcome this command can report, including a
1247
+ # failure before a `configuration` was ever built -- but on one of
1248
+ # the internal-failure backstop paths (`_internal_failure` and its
1249
+ # siblings), `target` is a fixed `zephyr-mcu`/`none` placeholder that
1250
+ # never learned what was actually asked for (tan-cli#1020 review),
1251
+ # so there `programsDevice` is present and CONSERVATIVE (`true`,
1252
+ # fail-safe), not necessarily an accurate answer for the target the
1253
+ # caller actually named.
1254
+ "programsDevice": programs_device(target),
1255
+ "notes": notes,
1256
+ # The launch configuration itself -- the very thing the command
1257
+ # produces. Additive: the envelope used to describe the write (path,
1258
+ # replaced, notes) without carrying the object, so an automated consumer
1259
+ # had to re-read launch.json or scrape the text preview to see what was
1260
+ # generated (alp-sdk-vscode#339).
1261
+ "configuration": configuration,
1262
+ }
1263
+
1264
+
1265
+ def _failure(
1266
+ *,
1267
+ generated_at: str,
1268
+ target: str,
1269
+ server: str,
1270
+ launch_json_path: str,
1271
+ exit_code: ExitCode,
1272
+ code: str,
1273
+ message: str,
1274
+ text_lines: list[str],
1275
+ ) -> _Outcome:
1276
+ """The shared failure outcome: one `error` issue, a `configuration: null`
1277
+ payload, and a NULL project (TS `createFailureResult` reports no project)."""
1278
+ return _Outcome(
1279
+ exit_code=exit_code,
1280
+ data=_data(
1281
+ generated_at=generated_at,
1282
+ target=target,
1283
+ server=server,
1284
+ preview=False,
1285
+ launch_json_path=launch_json_path,
1286
+ replaced=False,
1287
+ notes=[],
1288
+ # No draft exists on this path -- the failure happened before (or
1289
+ # instead of) generating one. `null`, not an empty object, so a
1290
+ # consumer cannot mistake it for a configuration with no fields.
1291
+ configuration=None,
1292
+ ),
1293
+ project=Project(root=None, board_yaml=None),
1294
+ issues=[Issue(f"debug-config.{code}", "error", message)],
1295
+ text=[*text_lines, message],
1296
+ )
1297
+
1298
+
1299
+ def _internal_failure(
1300
+ generated_at: str, message: str, launch_json_path: str
1301
+ ) -> _Outcome:
1302
+ """Invalid kind / unsupported backend / unreadable or malformed existing
1303
+ launch.json: exit 5, with a `zephyr-mcu`/`none` placeholder target (matching
1304
+ the TS catch block, which never learned what was actually asked for)."""
1305
+ return _failure(
1306
+ generated_at=generated_at,
1307
+ target=ZEPHYR_MCU,
1308
+ server=SERVER_NONE,
1309
+ launch_json_path=launch_json_path,
1310
+ exit_code=ExitCode.INTERNAL_FAILURE,
1311
+ code="internal-failure",
1312
+ message=message,
1313
+ text_lines=["debug-config: internal failure"],
1314
+ )
1315
+
1316
+
1317
+ def _build_manifest_missing_failure(
1318
+ generated_at: str, message: str, launch_json_path: str
1319
+ ) -> _Outcome:
1320
+ """tan-cli#462: a real hardware project (`som.sku` set) has not been built
1321
+ yet, so `--target-kind` cannot be inferred from a `build/system-
1322
+ manifest.yaml` that does not exist -- a precondition the CALLER can fix
1323
+ (`tan build` first, or an explicit `--target-kind`), not a tan-side
1324
+ crash. Exit 2, matching the sibling distinction tan-cli#262 settled for
1325
+ `tan validate`: a verdict the command CAN produce about the caller's own
1326
+ input is `VALIDATION_FAILURE`, never `INTERNAL_FAILURE` -- that exit
1327
+ stays reserved for the `except Exception` backstop below and an
1328
+ unreadable/malformed existing launch.json."""
1329
+ return _failure(
1330
+ generated_at=generated_at,
1331
+ target=ZEPHYR_MCU,
1332
+ server=SERVER_NONE,
1333
+ launch_json_path=launch_json_path,
1334
+ exit_code=ExitCode.VALIDATION_FAILURE,
1335
+ code="build-manifest-missing",
1336
+ message=message,
1337
+ text_lines=["debug-config: validation failure"],
1338
+ )
1339
+
1340
+
1341
+ def _project_not_found_failure(
1342
+ generated_at: str, message: str, launch_json_path: str
1343
+ ) -> _Outcome:
1344
+ """tan-cli#476: `--project` names a directory that does not exist.
1345
+
1346
+ Refused rather than created. `_write_launch_json` calls `mkdir(parents=
1347
+ True)`, so a typo'd `--project` (or a stale path in a script) silently
1348
+ MATERIALISED a project tree and dropped a `launch.json` in it, at exit 0
1349
+ with `issues: []`. Nothing downstream could tell that apart from writing
1350
+ into a real project.
1351
+
1352
+ A deliberate divergence from the oracle, which does the same thing --
1353
+ measured, not assumed: `target/release/tan debug-config --project <ghost>`
1354
+ exits 0 and leaves `<ghost>/.vscode/launch.json` behind. No parity CASE
1355
+ pins it (all four frozen `debug-config` argvs run in an existing
1356
+ `work_dir`), so this changes no frozen comparison. `--project` names a
1357
+ project that EXISTS; a path that does not is the caller's own input error,
1358
+ hence `ValidationFailure` (2), the same class as tan-cli#462's four.
1359
+
1360
+ A separate code from `_invalid_argument_failure` below, on purpose
1361
+ (tan-cli#508 review): this fires before target/server are even parsed --
1362
+ a precondition on the WORKSPACE, the same class as
1363
+ `_build_manifest_missing_failure` above, not a flag-value shape check."""
1364
+ return _failure(
1365
+ generated_at=generated_at,
1366
+ target=ZEPHYR_MCU,
1367
+ server=SERVER_NONE,
1368
+ launch_json_path=launch_json_path,
1369
+ exit_code=ExitCode.VALIDATION_FAILURE,
1370
+ code="project-not-found",
1371
+ message=message,
1372
+ text_lines=["debug-config: validation failure"],
1373
+ )
1374
+
1375
+
1376
+ def _target_kind_unresolved_failure(
1377
+ generated_at: str, message: str, launch_json_path: str
1378
+ ) -> _Outcome:
1379
+ """tan-cli#476 half (b): `--target-kind` was omitted and this project
1380
+ offers NO signal to infer one from -- no `build/system-manifest.yaml`, and
1381
+ no `board.yaml` declaring a `som.sku`. Refused rather than silently
1382
+ defaulting to `native-host`.
1383
+
1384
+ The other half of the same report. Half (a) -- a `--project` that does not
1385
+ exist -- is [`_project_not_found_failure`] above, fixed in tan-cli#508;
1386
+ that PR's own body records this half as "deliberately left". The two share
1387
+ one symptom: a `native_sim` launch configuration materialising, at exit 0
1388
+ with `issues: []`, in a directory that is not a project. #508 stopped the
1389
+ directory being CREATED; a directory that already exists (the "ran it from
1390
+ the wrong cwd" case the issue names in its own words) still got the file.
1391
+
1392
+ `parse_target_kind(None)` -> `NATIVE_HOST` is a REAL default for a
1393
+ project that says so -- `infer_target_kind` returns `NATIVE_HOST` outright
1394
+ when every slice a build produced is native_sim. This refusal fires only
1395
+ on the no-evidence case that default was standing in for, so a project
1396
+ with any signal at all is untouched, and `--target-kind native-host`
1397
+ remains the way to ask for exactly this draft on purpose.
1398
+
1399
+ Exit 2, the same class as `_build_manifest_missing_failure` above and for
1400
+ the same reason: a precondition on the WORKSPACE that the caller can fix,
1401
+ never a tan-side crash. A deliberate divergence from the Rust oracle,
1402
+ which exits 0 with the native-host draft -- measured, not inferred
1403
+ (`target/debug/tan --format json debug-config --project <empty> --preview`
1404
+ -> exit 0, `targetKind: "native-host"`). No parity CASE and no frozen
1405
+ conformance golden pins it: all five frozen `debug-config` argvs pass
1406
+ `--target-kind` explicitly, so none of them reaches the inference path at
1407
+ all."""
1408
+ return _failure(
1409
+ generated_at=generated_at,
1410
+ target=ZEPHYR_MCU,
1411
+ server=SERVER_NONE,
1412
+ launch_json_path=launch_json_path,
1413
+ exit_code=ExitCode.VALIDATION_FAILURE,
1414
+ code="target-kind-unresolved",
1415
+ message=message,
1416
+ text_lines=["debug-config: validation failure"],
1417
+ )
1418
+
1419
+
1420
+ def _invalid_argument_failure(
1421
+ generated_at: str,
1422
+ message: str,
1423
+ launch_json_path: str,
1424
+ target: str = ZEPHYR_MCU,
1425
+ server: str = SERVER_NONE,
1426
+ ) -> _Outcome:
1427
+ """tan-cli#477: a flag VALUE outside the set this command accepts.
1428
+
1429
+ Covers `--target-kind`, `--server`, an unsupported target+server pairing,
1430
+ `--svd` and `--gdbserver-address` -- every `DebugConfigError` raised while
1431
+ turning the caller's own arguments into a draft. All five already produced
1432
+ a complete, actionable message; only the verdict was wrong:
1433
+
1434
+ Unsupported --target-kind 'bogus'. Allowed values: zephyr-mcu,
1435
+ baremetal-mcu, yocto-userspace, native-host.
1436
+
1437
+ Reporting that as `internal failure` / exit 5 tells the user tan crashed
1438
+ and tells CI to treat a typo as a tool defect. tan-cli#462 made exactly
1439
+ this argument for the four PRECONDITIONS and reclassified them; the
1440
+ argument-validation half was not part of that change, so `--core bogus` --
1441
+ quoted verbatim in #462's own body -- still exited 5 in v0.5.1.
1442
+
1443
+ Exit 5 stays reserved for what `_build_manifest_missing_failure`'s
1444
+ docstring already names: the `except Exception` backstop, and an
1445
+ unreadable or malformed EXISTING launch.json. Neither is reachable from a
1446
+ flag value.
1447
+
1448
+ A DELIBERATE DIVERGENCE from the retired Rust oracle, which exited 5
1449
+ here -- measured (`target/release/tan debug-config --target-kind bogus` ->
1450
+ 5) while both implementations existed. `test_oracle_parity.py` pinned it
1451
+ ("Pins exit 5 ... across both implementations") until tan-cli#269 deleted
1452
+ that module with `crates/`, so there is no CASE table left to update in the
1453
+ same commit. The line is pinned tan-side instead, on both sides of it:
1454
+ `tests/commands/test_debug_config_command.py`'s
1455
+ `test_a_refused_selector_is_a_coded_envelope_at_exit_2` holds the 2, and
1456
+ `test_a_malformed_existing_launch_json_stays_an_internal_failure` holds the
1457
+ reserved 5."""
1458
+ return _failure(
1459
+ generated_at=generated_at,
1460
+ target=target,
1461
+ server=server,
1462
+ launch_json_path=launch_json_path,
1463
+ exit_code=ExitCode.VALIDATION_FAILURE,
1464
+ code="invalid-argument",
1465
+ message=message,
1466
+ text_lines=["debug-config: validation failure"],
1467
+ )
1468
+
1469
+
1470
+ def _core_unknown_failure(generated_at: str, message: str, launch_json_path: str) -> _Outcome:
1471
+ """tan-cli#462: an explicit `--core` (with `--target-kind` omitted) names
1472
+ no slice this project's own build produced -- the caller's own typo or a
1473
+ stale flag, not a tan-side crash. Exit 2, same reasoning as
1474
+ [`_build_manifest_missing_failure`] above."""
1475
+ return _failure(
1476
+ generated_at=generated_at,
1477
+ target=ZEPHYR_MCU,
1478
+ server=SERVER_NONE,
1479
+ launch_json_path=launch_json_path,
1480
+ exit_code=ExitCode.VALIDATION_FAILURE,
1481
+ code="core-unknown",
1482
+ message=message,
1483
+ text_lines=["debug-config: validation failure"],
1484
+ )
1485
+
1486
+
1487
+ def _explicit_core_unknown_failure(
1488
+ generated_at: str, target: str, server: str, message: str, launch_json_path: str
1489
+ ) -> _Outcome:
1490
+ """tan-cli#489 (5): the SAME `core-unknown` refusal as
1491
+ [`_core_unknown_failure`] above, reached from the OTHER side --
1492
+ `--target-kind` given EXPLICITLY, so `infer_target_kind` (and its own
1493
+ `--core`-vs-manifest guard) never runs at all, and an explicit `--core`
1494
+ naming no slice in this project's own build used to sail through in
1495
+ silence: exit 0, a placeholder `device`, and an `executable` pointing at
1496
+ a path that does not exist in this project. Same code, same exit, same
1497
+ caller-fixable cause -- only the TARGET/SERVER differ: both are already
1498
+ resolved here (unlike the omitted-`--target-kind` path, which fires
1499
+ before either is known), so this reports the REAL ones instead of
1500
+ `_core_unknown_failure`'s `zephyr-mcu`/`none` placeholder pair."""
1501
+ return _failure(
1502
+ generated_at=generated_at,
1503
+ target=target,
1504
+ server=server,
1505
+ launch_json_path=launch_json_path,
1506
+ exit_code=ExitCode.VALIDATION_FAILURE,
1507
+ code="core-unknown",
1508
+ message=message,
1509
+ text_lines=["debug-config: validation failure"],
1510
+ )
1511
+
1512
+
1513
+ def _target_kind_ambiguous_failure(
1514
+ generated_at: str, message: str, launch_json_path: str
1515
+ ) -> _Outcome:
1516
+ """tan-cli#462 review round: `--target-kind` omitted, and this project's
1517
+ own `build/system-manifest.yaml` -- well-formed, fully built, tan's own
1518
+ output -- names more than one target class (a mixed-core board, e.g.
1519
+ E1M-V2N101/V2M101's `a55_cluster`+`m33_sm`) with no `--core` to narrow
1520
+ them. Worse than the two refusals above: it hits every run against a
1521
+ CORRECT project forever, not just a pre-build one, and the remedy the
1522
+ message itself names (`--target-kind` + `--core`) works. Still the
1523
+ caller's own precondition, not a tan crash -- exit 2, same reasoning as
1524
+ [`_build_manifest_missing_failure`] above."""
1525
+ return _failure(
1526
+ generated_at=generated_at,
1527
+ target=ZEPHYR_MCU,
1528
+ server=SERVER_NONE,
1529
+ launch_json_path=launch_json_path,
1530
+ exit_code=ExitCode.VALIDATION_FAILURE,
1531
+ code="target-kind-ambiguous",
1532
+ message=message,
1533
+ text_lines=["debug-config: validation failure"],
1534
+ )
1535
+
1536
+
1537
+ def _core_required_failure(
1538
+ generated_at: str, target: str, server: str, message: str, launch_json_path: str
1539
+ ) -> _Outcome:
1540
+ """tan-cli#1488: `--core` omitted on a build with several cores of the
1541
+ target class. The caller's own precondition (exit 2), like `tan probe` /
1542
+ `tan flash`, which refuse the same case."""
1543
+ return _failure(
1544
+ generated_at=generated_at,
1545
+ target=target,
1546
+ server=server,
1547
+ launch_json_path=launch_json_path,
1548
+ exit_code=ExitCode.VALIDATION_FAILURE,
1549
+ code="core-required",
1550
+ message=message,
1551
+ text_lines=["debug-config: validation failure"],
1552
+ )
1553
+
1554
+
1555
+ def _no_debuggable_target_class_failure(
1556
+ generated_at: str, message: str, launch_json_path: str
1557
+ ) -> _Outcome:
1558
+ """tan-cli#462 review round: `--target-kind` omitted, hardware slices
1559
+ exist, but none of their `os` values is one `MANIFEST_OS_BY_TARGET` maps
1560
+ (e.g. a lone `os: linux` slice) -- a knowledge/version skew between tan
1561
+ and the SDK, not a crash: no invariant was violated and the command still
1562
+ produced a coherent verdict with a working remedy (`--target-kind`
1563
+ explicit). Exit 2, same reasoning as [`_build_manifest_missing_failure`]
1564
+ above."""
1565
+ return _failure(
1566
+ generated_at=generated_at,
1567
+ target=ZEPHYR_MCU,
1568
+ server=SERVER_NONE,
1569
+ launch_json_path=launch_json_path,
1570
+ exit_code=ExitCode.VALIDATION_FAILURE,
1571
+ code="no-debuggable-target-class",
1572
+ message=message,
1573
+ text_lines=["debug-config: validation failure"],
1574
+ )
1575
+
1576
+
1577
+ def _write_failure(
1578
+ generated_at: str, target: str, server: str, launch_json_path: str, message: str
1579
+ ) -> _Outcome:
1580
+ """A filesystem error while creating `.vscode/` or writing launch.json: exit
1581
+ 3, preserving the resolved target/server."""
1582
+ return _failure(
1583
+ generated_at=generated_at,
1584
+ target=target,
1585
+ server=server,
1586
+ launch_json_path=launch_json_path,
1587
+ exit_code=ExitCode.WRITE_FAILURE,
1588
+ code="write-failure",
1589
+ message=message,
1590
+ text_lines=["debug-config: failed to write launch.json."],
1591
+ )
1592
+
1593
+
1594
+ def _success_text(
1595
+ *,
1596
+ target: str,
1597
+ server: str,
1598
+ launch_json_path: str,
1599
+ replaced: bool,
1600
+ preview: bool,
1601
+ notes: list[str],
1602
+ configuration: Any,
1603
+ quiet: bool,
1604
+ issues: list[Issue],
1605
+ ) -> list[str]:
1606
+ """The human-readable lines for a successful preview or write."""
1607
+ lines: list[str] = []
1608
+ if preview:
1609
+ lines.append(f"debug-config: preview target={target} server={server}")
1610
+ lines.append(f"launch.json path: {launch_json_path}")
1611
+ if not quiet:
1612
+ lines.append("")
1613
+ lines.append(pretty_json(launch_preview_document(configuration)))
1614
+ lines.append("")
1615
+ lines.extend(f"note: {n}" for n in notes)
1616
+ return lines
1617
+ action = "updated" if replaced else "written"
1618
+ lines.append(f"debug-config: {action} target={target} server={server}")
1619
+ lines.append(f"launch.json: {launch_json_path}")
1620
+ # Always shown, even under --quiet: a one-time notice that the file just
1621
+ # lost a differently-named entry (folded into this one), or still holds a
1622
+ # stranded one, or lost a comment this run destroyed -- none of that is
1623
+ # routine noise like the resolution notes below it.
1624
+ for issue in issues:
1625
+ if issue.code in (
1626
+ "debug-config.legacy-entry-migrated",
1627
+ "debug-config.legacy-entry-untouched",
1628
+ ):
1629
+ lines.append(f"debug-config: {issue.message}")
1630
+ for issue in issues:
1631
+ if issue.code == "debug-config.comments-dropped":
1632
+ lines.append(f"note: {issue.message}")
1633
+ # tan-cli#1020 round 4: `debug-config.load-files-preserved` is the
1634
+ # `flash-path`/`safety` disclosure this whole issue exists for -- a
1635
+ # customer at a terminal (the DEFAULT output mode, not `--format json`)
1636
+ # must be told `executable` and the actually-programmed `loadFiles` now
1637
+ # disagree, the same way `comments-dropped` already is. Emitting it only
1638
+ # under `--format json` left `launch_provenance.py`'s own "disclosed,
1639
+ # every run" claim true for a consumer that never reads text output and
1640
+ # false for one that does: measured, a terminal run over the residual
1641
+ # (sidecar-lost-with-key-present) case printed three routine `note:`
1642
+ # lines and exited 0 with no hint `loadFiles` had gone stale. Always
1643
+ # shown, even under `--quiet`, for the same reason `comments-dropped` is.
1644
+ for issue in issues:
1645
+ if issue.code == "debug-config.load-files-preserved":
1646
+ lines.append(f"note: {issue.message}")
1647
+ if not quiet:
1648
+ lines.extend(f"note: {n}" for n in notes)
1649
+ return lines
1650
+
1651
+
1652
+ def _run(
1653
+ *,
1654
+ target_kind: str | None,
1655
+ server_arg: str | None,
1656
+ core: str | None,
1657
+ pre_launch_task: str | None,
1658
+ gdbserver_address: str | None,
1659
+ svd: str | None,
1660
+ preview: bool,
1661
+ project_arg: str,
1662
+ board_yaml_arg: str | None,
1663
+ sdk_root_arg: str | None,
1664
+ quiet: bool,
1665
+ ) -> _Outcome:
1666
+ """The whole command, as a pure-ish computation returning one outcome.
1667
+
1668
+ Nothing here emits or exits; [`debug_config`] does both exactly once. That
1669
+ split is what lets the exception guard wrap this call without swallowing
1670
+ `typer.Exit`.
1671
+ """
1672
+ generated_at = _generated_at()
1673
+ # Errors before target/server are even known report a cwd-based launch.json
1674
+ # path and a zephyr-mcu/none placeholder (matches the TS catch block).
1675
+ cwd_launch_path = os.path.join(os.getcwd(), ".vscode", "launch.json")
1676
+
1677
+ workspace_root = _normalise(project_arg)
1678
+ launch_json_path = os.path.join(workspace_root, ".vscode", "launch.json")
1679
+ # tan-cli#476, FIRST: a `--project` that does not exist is refused, not
1680
+ # created. Everything below this point can write, and the writer uses
1681
+ # `mkdir(parents=True)`.
1682
+ #
1683
+ # Review round: `os.path.isdir` alone cannot tell "missing" apart from
1684
+ # "exists but is a file", so the two get their own message rather than
1685
+ # both being told they "do not exist" -- a `--project` pointing at, say,
1686
+ # `board.yaml` genuinely exists, just not as a directory. And the
1687
+ # parenthetical resolved-path suffix is dropped when `project_arg` was
1688
+ # already absolute, since `_normalise` would otherwise repeat it verbatim.
1689
+ if not os.path.isdir(workspace_root):
1690
+ resolved_suffix = (
1691
+ "" if os.path.isabs(project_arg) else f" ({workspace_root})"
1692
+ )
1693
+ if os.path.exists(workspace_root):
1694
+ reason = "is not a directory"
1695
+ else:
1696
+ reason = "does not exist"
1697
+ return _project_not_found_failure(
1698
+ generated_at,
1699
+ f"--project {project_arg!r} {reason}{resolved_suffix}. "
1700
+ f"Name a project directory that already exists; debug-config "
1701
+ f"never creates one.",
1702
+ launch_json_path,
1703
+ )
1704
+ (
1705
+ project_root,
1706
+ board_yaml,
1707
+ sdk_root,
1708
+ sdk_source_tier,
1709
+ foreign_global_default_for,
1710
+ ) = _resolve_project_reporting_fields(project_arg, board_yaml_arg, sdk_root_arg)
1711
+ # tan-cli#236, the pair of #170 above: `boardYaml` reported only when a
1712
+ # file is really at the resolved path.
1713
+ project = Project.resolved(project_root, board_yaml)
1714
+
1715
+ # tan-cli#456: an omitted --target-kind must never silently default to
1716
+ # native-host on a project whose own build cannot produce that binary --
1717
+ # infer it from the project instead, and pick a matching --server default
1718
+ # too (leaving it at the literal "none" default would only trade one
1719
+ # broken draft for `create_launch_draft`'s "Unsupported debug backend
1720
+ # 'none'" refusal). Never overrides a target/server the caller named
1721
+ # explicitly. `infer_target_kind` itself is pure and shared (`tan.core.
1722
+ # debug_launch`); this is just its IO, the same best-effort reads as
1723
+ # everything else in this module.
1724
+ effective_target_kind = target_kind
1725
+ effective_server_arg = server_arg
1726
+ inferred: str | None = None
1727
+ if target_kind is None:
1728
+ manifest = _load_yaml(Path(workspace_root, "build", "system-manifest.yaml"))
1729
+ inferred, reason_code, ambiguous = infer_target_kind(
1730
+ manifest, core, _board_som_sku(board_yaml)
1731
+ )
1732
+ if ambiguous is not None:
1733
+ # `launch_json_path` -- this project's own -- not `cwd_launch_path`:
1734
+ # unlike the pre-#456 catch-all below, it is already resolved by
1735
+ # this point, so a cwd-based path here would just name whatever
1736
+ # directory the shell happened to be in.
1737
+ #
1738
+ # tan-cli#462: `reason_code`, when set, names which of
1739
+ # `infer_target_kind`'s refusals the caller can fix themselves --
1740
+ # VALIDATION_FAILURE (2), not INTERNAL_FAILURE (5). Review round:
1741
+ # its other two ambiguity shapes (more than one target class, or
1742
+ # none at all) are the SAME defect, not "unclassified" -- both
1743
+ # now carry their own code too, so this dispatches all four.
1744
+ if reason_code == "build-manifest-missing":
1745
+ return _build_manifest_missing_failure(generated_at, ambiguous, launch_json_path)
1746
+ if reason_code == "core-unknown":
1747
+ return _core_unknown_failure(generated_at, ambiguous, launch_json_path)
1748
+ if reason_code == "target-kind-ambiguous":
1749
+ return _target_kind_ambiguous_failure(generated_at, ambiguous, launch_json_path)
1750
+ if reason_code == "no-debuggable-target-class":
1751
+ return _no_debuggable_target_class_failure(
1752
+ generated_at, ambiguous, launch_json_path
1753
+ )
1754
+ return _internal_failure(generated_at, ambiguous, launch_json_path)
1755
+ if inferred is None:
1756
+ # tan-cli#476 half (b): NO signal at all -- `infer_target_kind`
1757
+ # returned `(None, None, None)`, its "nothing to go on" answer.
1758
+ # Falling through here reached `parse_target_kind(None)`, whose
1759
+ # `native-host` default then wrote an `Alp: Native Sim Debug`
1760
+ # entry into whatever directory `--project` happened to name.
1761
+ # That default is only defensible where the project SAYS it is a
1762
+ # native_sim project, which `infer_target_kind` answers with a
1763
+ # real `NATIVE_HOST` of its own, not with "no signal".
1764
+ return _target_kind_unresolved_failure(
1765
+ generated_at,
1766
+ f"--target-kind was not given, and {workspace_root} offers "
1767
+ "nothing to infer one from: it has no "
1768
+ "build/system-manifest.yaml, and no board.yaml declaring "
1769
+ "som.sku. Pass --target-kind explicitly (one of: "
1770
+ f"{', '.join(TARGET_KINDS)}) -- debug-config no longer "
1771
+ "defaults to native-host, which wrote a native_sim launch "
1772
+ "configuration into any directory it was pointed at "
1773
+ "(tan-cli#476). If this really is the project you meant, "
1774
+ "run tan build first, or check you are in the right "
1775
+ "directory.",
1776
+ launch_json_path,
1777
+ )
1778
+ effective_target_kind = inferred
1779
+ if server_arg is None:
1780
+ effective_server_arg = _DEFAULT_SERVER_FOR_TARGET.get(inferred)
1781
+
1782
+ try:
1783
+ target = parse_target_kind(effective_target_kind)
1784
+ server = parse_server_kind(effective_server_arg)
1785
+ except DebugConfigError as err:
1786
+ # Neither local is bound yet -- the zephyr-mcu/none placeholder is the
1787
+ # honest answer here (matches the TS catch block).
1788
+ return _invalid_argument_failure(generated_at, str(err), cwd_launch_path)
1789
+
1790
+ try:
1791
+ draft = create_launch_draft(target, server, pre_launch_task)
1792
+ except DebugConfigError as err:
1793
+ # #508 review, Major 4 follow-up: `target`/`server` ARE already bound
1794
+ # by this point (both parsed above) -- an unsupported PAIRING of two
1795
+ # individually-valid values must report the pairing it actually
1796
+ # refused, not the placeholder. Same reasoning as the `--svd` /
1797
+ # `--gdbserver-address` sites below, which already do this.
1798
+ return _invalid_argument_failure(
1799
+ generated_at, str(err), cwd_launch_path, target, server
1800
+ )
1801
+
1802
+ # tan-cli#964 review (major 5): collects every `som-preset-v2`/
1803
+ # `soc-spec-v1` schema violation found while EITHER SDK-metadata walk
1804
+ # below reads this project's SoM preset/SoC JSON -- `_sdk_published_cores`
1805
+ # (the --core guard, just below) and `_fill_debug_probe_identity_from_sdk`
1806
+ # (the identity fallback, further down) share this one list rather than
1807
+ # each collecting -- and reporting -- its own, so a violation both walks
1808
+ # would hit is not folded into two `debug-config.metadata-schema-invalid`
1809
+ # issues. Before this, `tan debug-config` passed no `warnings` to either
1810
+ # walk and validated NOTHING, despite #964's PR body claiming it inherited
1811
+ # the WARN half "transitively" through `read_sdk_som_and_soc`.
1812
+ schema_warnings: list[str] = []
1813
+ # tan-cli#964 review (major 6, "skip-but-disclose"): the same shared-list
1814
+ # convention as `schema_warnings` above, for the OTHER half -- a note
1815
+ # when the schema file itself is simply absent, rather than the silent
1816
+ # `[]` that used to be indistinguishable from "validated clean".
1817
+ schema_skipped: list[str] = []
1818
+
1819
+ # tan-cli#489 (5): an EXPLICIT --target-kind bypasses `infer_target_kind`
1820
+ # entirely, so its own --core-vs-manifest guard (the `core-unknown` refusal
1821
+ # above) never runs for this path. Without this check, --core naming no
1822
+ # slice in this project's own build sailed through in silence: exit 0, a
1823
+ # placeholder `device`, and an `executable` pointing at a path this
1824
+ # project's build never produced. Checked against the WHOLE manifest (any
1825
+ # os), mirroring `infer_target_kind`'s own guard exactly.
1826
+ #
1827
+ # tan-cli#477 major 2 closes the half #489 left open: with NO manifest at
1828
+ # all this used to stay silent, so an unknown --core on a real hardware
1829
+ # project sailed through at exit 0 with `device` left as the literal
1830
+ # `<resolved-device>` -- a launch.json that looks valid and fails later in
1831
+ # the debugger with nothing connecting the failure back to this command.
1832
+ # The reason it was left open was that `--core` pre-build has a SECOND,
1833
+ # legitimate job -- selecting which core's SDK-published debug-probe
1834
+ # identity to resolve (alp-sdk#1026, `identity_core = core or
1835
+ # build_core_id` below) -- which a guard keyed only on "does a manifest
1836
+ # exist" cannot tell apart from a typo "without also consulting the SDK's
1837
+ # own published core list". So it consults it: `_sdk_published_cores`
1838
+ # reads the same `--sdk-root` the identity fallback already does.
1839
+ #
1840
+ # The two authorities are ordered, never merged: a real build is the truth
1841
+ # about which cores THIS project produced, so when a manifest exists it
1842
+ # alone decides (a SoM core the project does not build must still be
1843
+ # refused). The SDK list stands in only where there is no manifest.
1844
+ # BOTH arms refuse only what they can PROVE unknown -- no slices / no
1845
+ # resolvable SDK core list means "cannot be asked", and staying silent
1846
+ # there is the existing, correct "not built yet" behaviour every other
1847
+ # placeholder-carrying draft already has. `--core` is not a per-server
1848
+ # flag, so this is server-independent: measured pre-fix, the openocd arm
1849
+ # reported only `sdk-identity-key-absent` (silent about the core) and the
1850
+ # pyocd arm reported `issues: []`.
1851
+ if target_kind is not None and core is not None:
1852
+ build_manifest = _load_yaml(Path(workspace_root, "build", "system-manifest.yaml"))
1853
+ all_slices = manifest_slices(build_manifest)
1854
+ if all_slices:
1855
+ if not any(s.get("core_id") == core for s in all_slices):
1856
+ return _explicit_core_unknown_failure(
1857
+ generated_at,
1858
+ target,
1859
+ server,
1860
+ explicit_core_unknown_message(core, all_slices),
1861
+ launch_json_path,
1862
+ )
1863
+ else:
1864
+ # NOT `sdk_root` directly: only a checkout this project is entitled
1865
+ # to be judged by may turn a `--core` into an exit-2 refusal. See
1866
+ # `_sdk_core_refusal_authority` (tan-cli#477 REVIEW round) -- the
1867
+ # identity fallback further down still reads `sdk_root` whatever
1868
+ # its provenance, because filling a placeholder from the wrong
1869
+ # checkout leaves a placeholder, while refusing on its word stops
1870
+ # the run.
1871
+ refusal_sdk = _sdk_core_refusal_authority(
1872
+ sdk_root, sdk_source_tier, foreign_global_default_for
1873
+ )
1874
+ published_cores = _sdk_published_cores(
1875
+ refusal_sdk, board_yaml, warnings=schema_warnings, skipped=schema_skipped
1876
+ )
1877
+ if published_cores and core not in published_cores:
1878
+ return _explicit_core_unknown_failure(
1879
+ generated_at,
1880
+ target,
1881
+ server,
1882
+ _sdk_core_unknown_message(
1883
+ core, published_cores, str(refusal_sdk), sdk_source_tier
1884
+ ),
1885
+ launch_json_path,
1886
+ )
1887
+
1888
+ # tan-cli#1488: several cores of this class and no --core -> refuse instead
1889
+ # of silently programming the first slice's core.
1890
+ if core is None:
1891
+ multi_core = multi_core_without_core_message(
1892
+ target, manifest_slices(_load_yaml(Path(workspace_root, "build", "system-manifest.yaml")))
1893
+ )
1894
+ if multi_core is not None:
1895
+ return _core_required_failure(
1896
+ generated_at, target, server, multi_core, launch_json_path
1897
+ )
1898
+
1899
+ # Fill the `<resolved-...>` placeholders from what this project's own build
1900
+ # recorded (#66). Nothing here fails the command: pre-build, or against a
1901
+ # Zephyr that reshaped `runners.yaml`, the draft keeps its placeholders.
1902
+ resolution, registered_runners, build_core_id = _resolve_from_build(
1903
+ workspace_root, target, server, core
1904
+ )
1905
+
1906
+ # alp-sdk#1026: whatever the build did NOT already resolve, try the SDK's
1907
+ # published per-variant debug-probe identity next -- `--core` if given,
1908
+ # else the core id the build itself just resolved. See
1909
+ # `_fill_debug_probe_identity_from_sdk`'s own docstring for why `device`
1910
+ # stays the placeholder on a never-built project with no `--core`.
1911
+ identity_core = core or build_core_id
1912
+ # Snapshot of what the BUILD (not the SDK) resolved, taken before the SDK
1913
+ # fallback runs, so `sdk_filled_json_fields` below can name exactly the
1914
+ # fields the SDK fallback itself just populated -- never a field a real
1915
+ # build's `runners.yaml` already resolved (that overwrite is pre-existing,
1916
+ # intended behaviour per `_merge_configuration`'s own doc comment, not
1917
+ # something this disclosure is scoped to cover).
1918
+ device_before_identity = resolution.device
1919
+ target_id_before_identity = resolution.target_id
1920
+ config_files_empty_before_identity = not resolution.config_files
1921
+ identity_debug_block_found, known_jlink_cores = _fill_debug_probe_identity_from_sdk(
1922
+ resolution, sdk_root, board_yaml, identity_core,
1923
+ warnings=schema_warnings, skipped=schema_skipped,
1924
+ )
1925
+ # Which launch-configuration JSON keys the SDK fallback (not a real build)
1926
+ # just populated -- the ONLY fields `sdk_identity_overwrites` below is
1927
+ # allowed to flag (alp-sdk#1026 review finding #1).
1928
+ sdk_filled_json_fields: list[str] = []
1929
+ if device_before_identity is None and resolution.device is not None:
1930
+ sdk_filled_json_fields.append("device")
1931
+ if target_id_before_identity is None and resolution.target_id is not None:
1932
+ sdk_filled_json_fields.append("targetId")
1933
+ if config_files_empty_before_identity and resolution.config_files:
1934
+ sdk_filled_json_fields.append("configFiles")
1935
+
1936
+ # `--svd` is the ONLY producer of `resolution.svd` (tan-cli#197): the SDK
1937
+ # ships no SVD, so without the flag the field is structurally always absent
1938
+ # and `apply_launch_resolution` drops both svd keys.
1939
+ if svd is not None:
1940
+ try:
1941
+ resolution.svd = _resolve_user_svd(workspace_root, svd)
1942
+ except DebugConfigError as err:
1943
+ # tan-cli#477 review: `target`/`server` ARE known here, unlike at
1944
+ # the parse site above -- reporting the zephyr-mcu/none
1945
+ # placeholder would misdescribe what the caller asked for, and a
1946
+ # validation verdict the extension may render must not.
1947
+ return _invalid_argument_failure(
1948
+ generated_at, str(err), launch_json_path, target, server
1949
+ )
1950
+
1951
+ # `--gdbserver-address` is the ONLY producer of `resolution.gdbserver_address`
1952
+ # (tan-cli#321): a runtime property of the deployed board, so nothing else
1953
+ # -- not a build, not SDK-published metadata -- can ever fill it.
1954
+ if gdbserver_address is not None:
1955
+ try:
1956
+ resolution.gdbserver_address = _resolve_gdbserver_address(gdbserver_address)
1957
+ except DebugConfigError as err:
1958
+ # Same as `--svd` above: both are known by this point.
1959
+ return _invalid_argument_failure(
1960
+ generated_at, str(err), launch_json_path, target, server
1961
+ )
1962
+
1963
+ apply_launch_resolution(draft, resolution)
1964
+
1965
+ # alp-sdk#1026 review finding #4: which server-identity field the SDK
1966
+ # fallback found a block for but could not fill -- checked against the
1967
+ # FINAL draft (after `apply_launch_resolution`), not the pre-resolution
1968
+ # one, or a field the fallback itself just resolved would misreport as
1969
+ # still absent. Advisory about resolution state, so it fires on
1970
+ # `--preview` too, not just a write.
1971
+ identity_issues: list[Issue] = [
1972
+ # tan-cli#964 review (major 5): the WARN half of #964's decided rule,
1973
+ # same shape `presets_cmd.py`/`size_cmd.py` already use -- one issue
1974
+ # per violation, naming the file, the JSON pointer, and what was
1975
+ # found. `debug-config` already degrades a schema-invalid preset/SoC
1976
+ # JSON to its existing placeholder behaviour (nothing above refuses
1977
+ # on `schema_warnings`); this makes that degrade visible instead of
1978
+ # silent.
1979
+ Issue("debug-config.metadata-schema-invalid", "warning", w)
1980
+ for w in schema_warnings
1981
+ ]
1982
+ # tan-cli#964 review (major 6, "skip-but-disclose"): `info`, not
1983
+ # `warning` -- nothing here says a document is wrong, only that a schema
1984
+ # to check it against is absent. Deduplicated: both walks above can each
1985
+ # find the same missing schema.
1986
+ identity_issues.extend(
1987
+ Issue("debug-config.metadata-schema-unchecked", "info", w)
1988
+ for w in dict.fromkeys(schema_skipped)
1989
+ )
1990
+ if identity_debug_block_found:
1991
+ field = _SERVER_IDENTITY_FIELD.get(server)
1992
+ if field is not None and _has_placeholder(draft.get(field)):
1993
+ # tan-cli#489 (4): `jlink_device` is the ONE field this identity
1994
+ # keys by core (`configFiles`/`targetId` are not core-dependent at
1995
+ # all -- `sdk-identity-key-absent` is always the right call for
1996
+ # those). For `device`, a placeholder surviving despite a found
1997
+ # debug block means the lookup itself never had a usable key: no
1998
+ # core was resolved, or the one resolved has no entry in the
1999
+ # published map -- never "the SDK publishes no value for this
2000
+ # SoM", which is what `sdk-identity-key-absent` would have said.
2001
+ #
2002
+ # Review round: `bool(known_jlink_cores)` is required in front of
2003
+ # the core-mismatch half. A SoM whose published `variants[].debug`
2004
+ # carries NO `jlink_device` key at all (real shape: today's Alif
2005
+ # entries publish `openocd_config` but not `jlink_device`) makes
2006
+ # `known_jlink_cores` the empty set regardless of `identity_core`
2007
+ # -- `core_unindexed` was unconditionally True, so even a VALID
2008
+ # `--core m55_hp` reported "has no `device` entry for core
2009
+ # 'm55_hp' -- its published cores are: none ... pass --core with
2010
+ # one of the cores above", advice that contradicts itself. That
2011
+ # SoM genuinely publishes no `device` value for ANY core --
2012
+ # `sdk-identity-key-absent` is the correct, and only correct,
2013
+ # code for it, exactly as it was before this split.
2014
+ core_unindexed = bool(known_jlink_cores) and (
2015
+ identity_core is None or identity_core not in known_jlink_cores
2016
+ )
2017
+ if server == JLINK and core_unindexed:
2018
+ identity_issues.append(
2019
+ _sdk_identity_core_unresolved_issue(identity_core, known_jlink_cores)
2020
+ )
2021
+ else:
2022
+ identity_issues.append(_sdk_identity_key_absent_issue(field))
2023
+ notes = _preview_notes_for(draft, registered_runners, server, target)
2024
+ # tan-cli#456 review: say when target/server were DERIVED, not requested --
2025
+ # otherwise silent, unlike the --svd/--gdbserver-address no-op notes right
2026
+ # below. Never fires for the no-signal native-host default (no manifest to
2027
+ # name).
2028
+ if target_kind is None and inferred is not None:
2029
+ server_clause = f" and --server '{effective_server_arg}'" if server_arg is None else ""
2030
+ notes.append(
2031
+ f"--target-kind was not given; inferred '{inferred}'{server_clause} from "
2032
+ "this project's build/system-manifest.yaml."
2033
+ )
2034
+ # A non-MCU draft carries no `svdFile` key at all, and
2035
+ # `apply_launch_resolution` only replaces keys that already exist -- so a
2036
+ # `--svd` here is a no-op. Say so rather than accepting the flag in silence
2037
+ # and leaving the user to wonder why no peripheral view appeared.
2038
+ if svd is not None and "svdFile" not in draft:
2039
+ notes.append(
2040
+ # `target`, the RESOLVED target -- `target_kind` is `None` on
2041
+ # every tan-cli#456 inference path and would mis-name it.
2042
+ f"--svd was given, but target kind '{target}' emits "
2043
+ "no svdFile field, so it had no effect: the Cortex Peripherals view "
2044
+ "is a cortex-debug (MCU) feature."
2045
+ )
2046
+ # Same "no silent no-op" floor as `--svd` above: only a yocto-userspace
2047
+ # draft carries `miDebuggerServerAddress` at all.
2048
+ if gdbserver_address is not None and "miDebuggerServerAddress" not in draft:
2049
+ notes.append(
2050
+ f"--gdbserver-address was given, but target kind "
2051
+ f"'{target}' emits no miDebuggerServerAddress "
2052
+ "field, so it had no effect: that field is a yocto-userspace "
2053
+ "(cppdbg) feature."
2054
+ )
2055
+
2056
+ def success(
2057
+ *, replaced: bool, configuration: Any, issues: list[Issue], is_preview: bool
2058
+ ) -> _Outcome:
2059
+ # tan-cli#321: checked against `configuration` -- the value ACTUALLY
2060
+ # going out (the fresh `draft` on `--preview`, the merged
2061
+ # `written_configuration` on a write) -- not the pre-merge `draft`
2062
+ # this closure captures from its enclosing scope. A write that merged
2063
+ # over a customer's own already-hand-filled address must not re-nag
2064
+ # them every run; checking the final value is what tells the two
2065
+ # apart, the same distinction `_has_placeholder` exists for.
2066
+ final_issues = list(issues)
2067
+ if target == YOCTO_USERSPACE and isinstance(configuration, dict):
2068
+ if _has_placeholder(configuration.get("miDebuggerServerAddress")):
2069
+ final_issues.append(_gdbserver_address_unresolved_issue())
2070
+ return _Outcome(
2071
+ exit_code=ExitCode.SUCCESS,
2072
+ data=_data(
2073
+ generated_at=generated_at,
2074
+ target=target,
2075
+ server=server,
2076
+ preview=is_preview,
2077
+ launch_json_path=launch_json_path,
2078
+ replaced=replaced,
2079
+ notes=notes,
2080
+ configuration=configuration,
2081
+ ),
2082
+ project=project,
2083
+ issues=final_issues,
2084
+ text=_success_text(
2085
+ target=target,
2086
+ server=server,
2087
+ launch_json_path=launch_json_path,
2088
+ replaced=replaced,
2089
+ preview=is_preview,
2090
+ notes=notes,
2091
+ configuration=configuration,
2092
+ quiet=quiet,
2093
+ issues=final_issues,
2094
+ ),
2095
+ )
2096
+
2097
+ if preview:
2098
+ # `--preview` never merges anything (it returns before the customer's
2099
+ # file is even read), so it reports the fresh draft -- which is also all
2100
+ # there is. tan-cli#180's preview-side invariant, and what the four
2101
+ # `debug-config-preview-*` goldens pin.
2102
+ return success(
2103
+ replaced=False, configuration=draft, issues=identity_issues, is_preview=True
2104
+ )
2105
+
2106
+ # Write mode: merge into .vscode/launch.json.
2107
+ try:
2108
+ Path(launch_json_path).parent.mkdir(parents=True, exist_ok=True)
2109
+ except OSError as err:
2110
+ return _write_failure(
2111
+ generated_at, target, server, launch_json_path, str(err)
2112
+ )
2113
+
2114
+ # A READ error on an EXISTING launch.json (wrong encoding, e.g. UTF-16LE
2115
+ # from PowerShell `>` redirection; a denied ACL; a sharing violation) must
2116
+ # NOT collapse into the same "no file yet" as absence. That fed a fresh
2117
+ # document to the write plan and the write then overwrote the user's file
2118
+ # wholesale -- silently destroying every hand-written debug configuration at
2119
+ # exit 0. The malformed-JSON case below is deliberately guarded (no write);
2120
+ # a read error must refuse to write for the same reason.
2121
+ existing: str | None = None
2122
+ if Path(launch_json_path).exists():
2123
+ try:
2124
+ # `newline=""` on the READ as well as the write: the default
2125
+ # universal-newlines mode translates every `\r\n` to `\n` before the
2126
+ # splice ever sees the text, so `jsonc_splice`'s dominant-EOL check
2127
+ # found no CRLF and re-wrote a Windows-authored launch.json LF-only.
2128
+ # Rust's `read_to_string` does not translate; neither may this.
2129
+ with open(launch_json_path, encoding="utf-8", newline="") as handle:
2130
+ existing = handle.read()
2131
+ except (OSError, UnicodeDecodeError) as err:
2132
+ return _internal_failure(
2133
+ generated_at,
2134
+ f"Alp: failed to read existing .vscode/launch.json: {err}",
2135
+ cwd_launch_path,
2136
+ )
2137
+
2138
+ # tan-cli#518: the `.alp/` provenance sidecar, read the SAME best-effort
2139
+ # way `_write_project_sdk_pointer`'s own reads are (`bootstrap_cmd.py`) --
2140
+ # ANY failure (absent file, a read error, unparsable/unrecognised JSON;
2141
+ # see `launch_provenance.load`'s own docstring) degrades to `empty()`,
2142
+ # never to "everything is ours". This is deliberately NOT held to the
2143
+ # same read-error-must-refuse-to-write bar as `launch.json` itself just
2144
+ # above: losing provenance only makes the NEXT merge more conservative
2145
+ # (append instead of overwrite), never destructive, so there is nothing
2146
+ # here worth refusing the write over.
2147
+ provenance_path = launch_provenance.sidecar_path(workspace_root)
2148
+ provenance_content: str | None = None
2149
+ # tan-cli#1116 review round 2: no `is_file()` pre-flight -- `Path.is_file()`
2150
+ # (pre-3.14) swallows ENOENT/ENOTDIR/EBADF/ELOOP but not EACCES, and on
2151
+ # 3.14+ it swallows EVERY OSError including EACCES and returns `False`,
2152
+ # which would have taken this "absent" arm for a permission-denied
2153
+ # sidecar instead of the "read error" one two paragraphs up documents.
2154
+ # `UnicodeDecodeError` is caught alongside `OSError` for the same reason
2155
+ # named there: it is a `ValueError`, not an `OSError`, so a non-UTF-8
2156
+ # sidecar used to escape this "ANY failure ... degrades to empty()"
2157
+ # contract raw.
2158
+ try:
2159
+ with open(provenance_path, encoding="utf-8") as handle:
2160
+ provenance_content = handle.read()
2161
+ except (OSError, UnicodeDecodeError):
2162
+ provenance_content = None
2163
+ provenance = launch_provenance.load(provenance_content)
2164
+
2165
+ # alp-sdk#1026 review finding #1: compute this BEFORE the write, against
2166
+ # the file as it stood -- `create_launch_json_write_plan` below already
2167
+ # performs the same overwrite (that part of its behaviour is intentional,
2168
+ # see `_merge_configuration`'s own doc comment), this only detects it so
2169
+ # it can be disclosed. tan-cli#518: `provenance` is passed through so a
2170
+ # LIST field (`configFiles`) is only reported as overwritten when the
2171
+ # real merge below would actually do that -- see `sdk_identity_overwrites`'s
2172
+ # own doc comment on why an honest "would this actually happen" beats an
2173
+ # unconditional "the values differ".
2174
+ overwrites = sdk_identity_overwrites(
2175
+ existing, draft, sdk_filled_json_fields, provenance=provenance
2176
+ )
2177
+ # tan-cli#982 review finding #2: the OTHER outcome that same merge can
2178
+ # produce for a list field -- an existing value provenance could not
2179
+ # prove was tan's own is left in place and the new one is appended
2180
+ # beside it, rather than replaced. `sdk_identity_overwrites` above
2181
+ # correctly stays silent about this shape (nothing concrete was lost);
2182
+ # this discloses the append instead, so it is not silent everywhere.
2183
+ stranded_appends = sdk_identity_stranded_appends(
2184
+ existing, draft, sdk_filled_json_fields, provenance=provenance
2185
+ )
2186
+ # tan-cli#1020 review: the SAME "mirror the real merge, then disclose"
2187
+ # discipline as the two calls above, for the ONE field neither of them
2188
+ # covers -- `loadFiles` is populated by the draft itself (tan-cli#945),
2189
+ # never by the SDK-identity fallback those two are scoped to, and its
2190
+ # own merge rule (`_merge_load_files`) protects a hand-authored value
2191
+ # WHOLESALE rather than appending beside it.
2192
+ preserved_load_files = load_files_preserved(existing, draft, provenance)
2193
+
2194
+ # tan-cli#489 (6): `--pre-launch-task ''` opts OUT of a `preLaunchTask` key
2195
+ # entirely (`create_launch_draft` builds it, then deletes it), which is
2196
+ # indistinguishable, to the merge's own "only visit the draft's own keys"
2197
+ # rule, from this target simply having no default -- so the opt-out was a
2198
+ # silent no-op against an entry a PRIOR run already gave one. Named here,
2199
+ # explicitly, rather than left for `create_launch_json_write_plan` to
2200
+ # infer from the draft alone.
2201
+ explicit_omissions = frozenset({"preLaunchTask"}) if pre_launch_task == "" else frozenset()
2202
+
2203
+ try:
2204
+ plan = create_launch_json_write_plan(
2205
+ existing, draft, explicit_omissions, provenance=provenance
2206
+ )
2207
+ except DebugConfigError as err:
2208
+ # A malformed existing launch.json surfaces as an internal failure in TS.
2209
+ return _internal_failure(generated_at, str(err), cwd_launch_path)
2210
+
2211
+ # `open(launch_json_path, "w")` truncates the customer's file to zero
2212
+ # before a single byte of `plan.content` is written, and that content
2213
+ # exists only in memory -- a failure between the truncate and the flush
2214
+ # (ENOSPC, a quota/RLIMIT_FSIZE hit, an I/O error, or the process dying:
2215
+ # SIGKILL, power loss, the VS Code extension killing the child) destroys
2216
+ # every hand-written configuration with no way for tan to repair it: the
2217
+ # next run reads the wreckage, hits the malformed-JSON guard above, and
2218
+ # refuses at exit 5 forever. `atomic_write_text`'s own docstring
2219
+ # (`tan/core/atomic_write.py`) covers the rest: symlink-safe, fsync'd
2220
+ # before the rename, and mode-preserving -- shared with
2221
+ # `bootstrap_cmd.reconcile_west_manifest_path` (tan-cli#516) rather than
2222
+ # a second hand-synchronised copy living beside this call site.
2223
+ try:
2224
+ atomic_write_text(launch_json_path, plan.content)
2225
+ except OSError as err:
2226
+ return _write_failure(
2227
+ generated_at, target, server, launch_json_path, str(err)
2228
+ )
2229
+
2230
+ # tan-cli#518: persist the updated provenance sidecar AFTER launch.json
2231
+ # itself is safely on disk, and best-effort -- a failure here never fails
2232
+ # the command (the launch.json write is the one that matters and it
2233
+ # already succeeded) and never leaves a half-written sidecar
2234
+ # (`atomic_write_text` again), it just means the NEXT run degrades back
2235
+ # to `launch_provenance.empty()`'s conservative default for whatever this
2236
+ # write recorded, exactly as if this run had never touched the sidecar
2237
+ # at all.
2238
+ try:
2239
+ provenance_path.parent.mkdir(parents=True, exist_ok=True)
2240
+ atomic_write_text(str(provenance_path), launch_provenance.render(plan.provenance))
2241
+ except OSError:
2242
+ pass
2243
+
2244
+ issues: list[Issue] = list(identity_issues)
2245
+ if plan.migrated_from is not None:
2246
+ issues.append(_migrated_issue(plan.migrated_from, draft.get("name", "")))
2247
+ if plan.legacy_entry_present is not None:
2248
+ issues.append(_legacy_untouched_issue(plan.legacy_entry_present))
2249
+ if plan.comments_dropped:
2250
+ issues.append(_comments_dropped_issue())
2251
+ # alp-sdk#1026 review finding #1: this write just replaced a concrete
2252
+ # existing value with one resolved from the SDK's published debug-probe
2253
+ # identity rather than a real build -- say so, the same way a dropped
2254
+ # comment is disclosed rather than left for the customer to notice by
2255
+ # diffing the file themselves.
2256
+ for field, existing_value, incoming_value in overwrites:
2257
+ issues.append(
2258
+ _sdk_identity_overwrite_issue(field, existing_value, incoming_value)
2259
+ )
2260
+ for field, existing_value, incoming_value in stranded_appends:
2261
+ issues.append(
2262
+ _sdk_identity_appended_issue(field, existing_value, incoming_value)
2263
+ )
2264
+ if preserved_load_files is not None:
2265
+ issues.append(_load_files_preserved_issue(*preserved_load_files))
2266
+
2267
+ return success(
2268
+ replaced=plan.replaced,
2269
+ # tan-cli#180: report what this write actually put in the file -- the
2270
+ # merged/migrated result -- never the fresh `draft`, which still carries
2271
+ # its own `<resolved-...>` placeholders even after a merge resolved them
2272
+ # from the customer's real, hand-filled values.
2273
+ configuration=plan.written_configuration,
2274
+ issues=issues,
2275
+ is_preview=False,
2276
+ )
2277
+
2278
+
2279
+ def debug_config(
2280
+ ctx: typer.Context,
2281
+ target_kind: str = typer.Option(
2282
+ None,
2283
+ "--target-kind",
2284
+ metavar="KIND",
2285
+ help="Debug target class (zephyr-mcu, baremetal-mcu, yocto-userspace, native-host).",
2286
+ ),
2287
+ server: str = typer.Option(
2288
+ None,
2289
+ "--server",
2290
+ metavar="SERVER",
2291
+ help="Debug server backend (jlink, openocd, pyocd, gdbserver, none).",
2292
+ ),
2293
+ core: str = typer.Option(
2294
+ None,
2295
+ "--core",
2296
+ metavar="CORE_ID",
2297
+ help=(
2298
+ "Resolve against the build slice with this core_id. Defaults to the "
2299
+ "first slice matching the target class's OS."
2300
+ ),
2301
+ ),
2302
+ pre_launch_task: str = typer.Option(
2303
+ None,
2304
+ "--pre-launch-task",
2305
+ metavar="TASK",
2306
+ help=(
2307
+ "Emit preLaunchTask: <TASK> on the generated configuration. "
2308
+ "Defaults to the v0.3.1 task name for this target (tan-cli#138): "
2309
+ "'alp: build active target' (zephyr-mcu), 'alp: build baremetal "
2310
+ "target' (baremetal-mcu), 'alp: build native_sim target' "
2311
+ "(native-host). yocto-userspace carries no default (tan-cli#321: "
2312
+ "the extension's only registered task for it exits 1 by design) "
2313
+ "-- pass this flag explicitly to add a reminder. Pass an empty "
2314
+ "string to omit the key entirely."
2315
+ ),
2316
+ ),
2317
+ gdbserver_address: str = typer.Option(
2318
+ None,
2319
+ "--gdbserver-address",
2320
+ metavar="HOST:PORT",
2321
+ help=(
2322
+ "Fill miDebuggerServerAddress on a yocto-userspace configuration "
2323
+ "(tan-cli#321). This is a runtime property of the deployed board "
2324
+ "that no build can resolve; without it the field stays the "
2325
+ "<host>:<port> placeholder and F5 fails at connect."
2326
+ ),
2327
+ ),
2328
+ svd: str = typer.Option(
2329
+ None,
2330
+ "--svd",
2331
+ metavar="PATH",
2332
+ help=(
2333
+ "Point cortex-debug's Cortex Peripherals view at an SVD file you "
2334
+ "supply. The SDK ships none (alp-sdk#948). A relative path resolves "
2335
+ "against the current directory; an unreadable one fails the command."
2336
+ ),
2337
+ ),
2338
+ preview: bool = typer.Option(
2339
+ False, "--preview", help="Print the launch configuration without writing launch.json."
2340
+ ),
2341
+ project: str = typer.Option(
2342
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
2343
+ ),
2344
+ board_yaml: str = typer.Option(
2345
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
2346
+ ),
2347
+ sdk_root: str = typer.Option( # read for the alp-sdk#1026 metadata fallback; see below
2348
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
2349
+ ),
2350
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
2351
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress non-essential output."),
2352
+ ) -> None:
2353
+ """Generate (or preview) a VS Code launch.json debug configuration.
2354
+
2355
+ `--sdk-root` is read, but only as a best-effort, silent enrichment
2356
+ (alp-sdk#1026): a resolved checkout's `metadata/**` fills whatever the
2357
+ build itself did not already resolve (`_fill_debug_probe_identity_from_sdk`),
2358
+ but is never shelled and never reported as an `sdk` envelope dependency,
2359
+ matching the module docstring above. clap makes `--sdk-root` `global =
2360
+ true` in Rust regardless, so `tan --sdk-root X debug-config` must not be a
2361
+ parse error even when `X` names no real checkout -- an unresolvable value
2362
+ changes nothing in the envelope (the fallback silently finds nothing),
2363
+ same as the oracle.
2364
+ """
2365
+ # `--format` is accepted BEFORE the subcommand too (`tan --format json
2366
+ # debug-config ...`, which is what the committed goldens invoke and what
2367
+ # clap's `global = true` gives the Rust); the root callback records it and
2368
+ # this option overrides it when repeated after the command name.
2369
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
2370
+ json_mode = resolved_format == "json"
2371
+
2372
+ try:
2373
+ outcome = _run(
2374
+ target_kind=target_kind,
2375
+ server_arg=server,
2376
+ core=core,
2377
+ pre_launch_task=pre_launch_task,
2378
+ gdbserver_address=gdbserver_address,
2379
+ svd=svd,
2380
+ preview=preview,
2381
+ project_arg=project or ".",
2382
+ board_yaml_arg=board_yaml,
2383
+ sdk_root_arg=sdk_root,
2384
+ quiet=quiet,
2385
+ )
2386
+ except Exception as err: # noqa: BLE001
2387
+ # The recurring break this guard exists for: an escaping traceback puts
2388
+ # nothing parseable on stdout, and the extension renders an empty panel
2389
+ # with no error at all. Any unforeseen failure becomes a coded envelope.
2390
+ # `typer.Exit` cannot reach here -- `_run` never raises it (it returns an
2391
+ # outcome) -- which is why the emit/exit pair lives outside this try:
2392
+ # `typer.Exit` subclasses RuntimeError, not SystemExit, so a bare
2393
+ # `except Exception` around it would swallow the process exit.
2394
+ outcome = _internal_failure(
2395
+ _generated_at(),
2396
+ f"debug-config failed unexpectedly: {err}",
2397
+ os.path.join(os.getcwd(), ".vscode", "launch.json"),
2398
+ )
2399
+
2400
+ if json_mode:
2401
+ emit(
2402
+ Envelope(
2403
+ "debug-config",
2404
+ outcome.project,
2405
+ outcome.data,
2406
+ outcome.issues,
2407
+ outcome.exit_code,
2408
+ )
2409
+ )
2410
+ else:
2411
+ # stdout is the envelope channel and carries nothing else, in either
2412
+ # mode; stderr carries no contract of its own.
2413
+ stream = typer.get_text_stream("stderr")
2414
+ for line in outcome.text:
2415
+ stream.write(f"{line}\n")
2416
+ raise typer.Exit(int(outcome.exit_code))
2417
+
2418
+
2419
+ # tan-cli#261: adds the six oracle `GlobalArgs` flags this command was still
2420
+ # missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--target`/
2421
+ # `--verbose`) on top of `--quiet`, already declared and read above; see
2422
+ # `tan.core.global_flags`. `ctx: typer.Context` (this command's own
2423
+ # `_HONOURS_ROOT_FORMAT` seam) is untouched -- appended parameters are all
2424
+ # keyword-only Options, never repositioned relative to it.
2425
+ debug_config = accept_global_flags(debug_config)