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,1803 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Debug launch-config generation: the target/server vocabulary, the launch
3
+ draft per target class, the resolution overlay, and the ``launch.json`` merge
4
+ plan. Port of ``crates/tan-core/src/debug{,_launch}.rs``.
5
+
6
+ Two things carry over from the Rust as invariants rather than incidentals:
7
+
8
+ * **Key order is contract.** The emitted configuration must match the TS CLI's
9
+ key order, which is why the Rust needs serde_json's ``preserve_order``
10
+ feature and calls ``shift_remove`` (never the order-scrambling swap-``remove``)
11
+ when it drops a key. A Python ``dict`` is insertion-ordered and ``del``
12
+ removes in place, so the trap does not exist here -- but the reason a key is
13
+ BUILT and then deleted, rather than conditionally inserted, is exactly this:
14
+ ``preLaunchTask`` and the two svd keys sit in the MIDDLE of the order.
15
+ * **The OS/target class is derived, never selected.** ``--target-kind`` names a
16
+ debug target CLASS (zephyr-mcu / baremetal-mcu / yocto-userspace /
17
+ native-host); each class implies its adapter, its artefact shape and its
18
+ server set. Nothing here knows a SKU, an address, a pin name or a vendor --
19
+ those live in alp-sdk ``metadata/``. There is no ``--os`` and no
20
+ ``--backend``.
21
+
22
+ Target/server kinds are plain wire strings, not an enum: the Rust needs
23
+ ``DebugTargetKind`` + ``as_str()`` + a serde rename to get "kebab-case on the
24
+ wire from a typed value in the code", and every one of those three renders the
25
+ same literal. Here the literal IS the value, so the envelope cannot disagree
26
+ with the parser about how a kind is spelled.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ from dataclasses import dataclass, field
33
+ from typing import Any, Callable
34
+
35
+ from tan.core import jsonc_splice, launch_provenance
36
+ from tan.core.launch_provenance import LaunchProvenance
37
+ from tan.core.run import is_native_sim_board
38
+ from tan.core.system_manifest import SYSTEM_MANIFEST_SCHEMA_VERSION
39
+
40
+ ZEPHYR_MCU = "zephyr-mcu"
41
+ BAREMETAL_MCU = "baremetal-mcu"
42
+ YOCTO_USERSPACE = "yocto-userspace"
43
+ NATIVE_HOST = "native-host"
44
+
45
+ JLINK = "jlink"
46
+ OPENOCD = "openocd"
47
+ PYOCD = "pyocd"
48
+ GDBSERVER = "gdbserver"
49
+ SERVER_NONE = "none"
50
+
51
+ #: Allowed `--target-kind` values, in the order the error message lists them.
52
+ TARGET_KINDS = (ZEPHYR_MCU, BAREMETAL_MCU, YOCTO_USERSPACE, NATIVE_HOST)
53
+ #: Allowed `--server` values, in the order the error message lists them.
54
+ SERVER_KINDS = (JLINK, OPENOCD, PYOCD, GDBSERVER, SERVER_NONE)
55
+
56
+ _SERVER_LABELS = {
57
+ JLINK: "J-Link",
58
+ OPENOCD: "OpenOCD",
59
+ PYOCD: "pyOCD",
60
+ GDBSERVER: "gdbserver",
61
+ SERVER_NONE: "local",
62
+ }
63
+
64
+ #: The debug servers valid for each target kind.
65
+ _SERVER_CHOICES = {
66
+ ZEPHYR_MCU: (JLINK, OPENOCD, PYOCD),
67
+ BAREMETAL_MCU: (JLINK, OPENOCD, PYOCD),
68
+ YOCTO_USERSPACE: (GDBSERVER,),
69
+ NATIVE_HOST: (SERVER_NONE,),
70
+ }
71
+
72
+
73
+ class DebugConfigError(Exception):
74
+ """A refusal whose ``str()`` is the user-facing message, verbatim from the
75
+ Rust. Raised instead of returning ``Result<_, String>``; every caller turns
76
+ it into a coded envelope, never a traceback."""
77
+
78
+
79
+ def parse_target_kind(raw: str | None) -> str:
80
+ """``--target-kind``: an absent/empty value defaults to ``native-host`` (TS
81
+ ``parseTargetKind``); an unknown value is an error."""
82
+ value = raw or ""
83
+ if value == "":
84
+ return NATIVE_HOST
85
+ if value in TARGET_KINDS:
86
+ return value
87
+ raise DebugConfigError(
88
+ f"Unsupported --target-kind '{value}'. Allowed values: "
89
+ f"{', '.join(TARGET_KINDS)}."
90
+ )
91
+
92
+
93
+ def parse_server_kind(raw: str | None) -> str:
94
+ """``--server``: absent/empty defaults to ``none``; unknown is an error."""
95
+ value = raw or ""
96
+ if value == "":
97
+ return SERVER_NONE
98
+ if value in SERVER_KINDS:
99
+ return value
100
+ raise DebugConfigError(
101
+ f"Unsupported --server '{value}'. Allowed values: {', '.join(SERVER_KINDS)}."
102
+ )
103
+
104
+
105
+ def server_choices_for_target(target: str) -> tuple[str, ...]:
106
+ return _SERVER_CHOICES.get(target, ())
107
+
108
+
109
+ def is_server_supported_for_target(target: str, server: str) -> bool:
110
+ return server in server_choices_for_target(target)
111
+
112
+
113
+ #: tan-cli#945: whether STARTING a debug session for this target class writes
114
+ #: program data to the attached target, stated by the producer instead of left
115
+ #: for a consumer to re-derive from the cortex-debug adapter's own schema
116
+ #: knowledge -- its ``loadFiles`` default ("if this property does not exist,
117
+ #: then the executable is used to program the device", `marus25.cortex-debug`
118
+ #: 1.12.1) plus any of thirteen ``*Commands`` lists that could carry a bare
119
+ #: ``load``. alp-sdk-vscode#586/#595 had to reimplement exactly that inference
120
+ #: client-side because neither of its flash gates could see the path -- the
121
+ #: programming happens inside cortex-debug's own spawned `JLinkGDBServerCL` /
122
+ #: `openocd` / `pyocd`, with no `tan` process in the loop to intercept.
123
+ #:
124
+ #: ``True`` for both cortex-debug targets: :func:`create_launch_draft` always
125
+ #: emits ``request: launch`` with a non-empty ``loadFiles`` -- every FRESH
126
+ #: draft this module produces for them programs the executable onto the
127
+ #: target. Keyed on ``targetKind`` alone, not on the ``loadFiles`` a given
128
+ #: WRITE actually lands: after tan-cli#1020's re-review, a MERGE over an
129
+ #: existing entry can protect a customer's own explicit attach-only ``[]``
130
+ #: (see :func:`_merge_load_files`), so a single exit-0 payload can legitimately
131
+ #: carry ``programsDevice: true`` beside a written ``configuration.loadFiles``
132
+ #: of ``[]`` -- the class of session this constant's docstring used to say
133
+ #: could not happen. That is the same fail-safe direction every other caller
134
+ #: of this map already leans on (overstating the write is confusing, not
135
+ #: unsafe), so it is left as-is rather than keyed per-write; a consumer that
136
+ #: needs per-write precision must read ``data.configuration.loadFiles``
137
+ #: itself, not just ``programsDevice``. ``False`` for yocto-userspace: its
138
+ #: ``cppdbg`` ``launch`` request attaches MI to a gdbserver the operator
139
+ #: already started, after their own manual deploy
140
+ #: (``miDebuggerServerAddress``) -- this session issues no write of its own.
141
+ #: ``False`` for native-host: ``lldb`` runs a binary on the HOST machine: there
142
+ #: is no target hardware to program at all.
143
+ PROGRAMS_DEVICE = {
144
+ ZEPHYR_MCU: True,
145
+ BAREMETAL_MCU: True,
146
+ YOCTO_USERSPACE: False,
147
+ NATIVE_HOST: False,
148
+ }
149
+
150
+
151
+ def programs_device(target: str) -> bool:
152
+ """Whether a launch of ``target`` writes to real hardware (tan-cli#945).
153
+
154
+ Defaults to ``False`` for an unrecognised target -- the same "assume it
155
+ does NOT program" floor every caller of this module already applies to an
156
+ unresolved target/server pairing elsewhere: overstating the safe case
157
+ (claiming a write when there is none) is merely confusing, understating it
158
+ (claiming safety for a session that flashes silicon) is the class of bug
159
+ this whole issue exists to close.
160
+ """
161
+ return PROGRAMS_DEVICE.get(target, False)
162
+
163
+
164
+ #: The ``build/system-manifest.yaml`` slice ``os`` a debug target class runs
165
+ #: on, or absent for a target with no per-core build slice keyed by ``os``.
166
+ #: ``native-host`` is exactly that case -- its slice is picked by BOARD target
167
+ #: instead (:func:`tan.core.run.is_native_sim_board`), never by this map.
168
+ MANIFEST_OS_BY_TARGET = {
169
+ ZEPHYR_MCU: "zephyr",
170
+ BAREMETAL_MCU: "baremetal",
171
+ YOCTO_USERSPACE: "yocto",
172
+ }
173
+
174
+
175
+ def manifest_slices(manifest: Any) -> list[dict[str, Any]]:
176
+ """``build/system-manifest.yaml``'s slices, or ``[]`` when the document is
177
+ not a v1 manifest. A TOLERANT reader, deliberately distinct from
178
+ :func:`tan.core.system_manifest.parse_system_manifest`'s strict one:
179
+ every caller here treats the file as a best-effort enrichment (pre-build,
180
+ or against a reshaped manifest, nothing resolves rather than the command
181
+ failing), so a structurally wrong document degrades to "no slices" rather
182
+ than raising.
183
+
184
+ The schema-major guard mirrors the strict reader, which REFUSES a
185
+ manifest whose ``schema_version`` is not 1 rather than reading it as if it
186
+ were. A slice missing ``core_id`` or ``os`` also disqualifies the whole
187
+ document: both fields are non-``Option`` in the Rust struct, so serde
188
+ fails the entire parse there, and a partial read here would resolve
189
+ against a manifest the oracle rejects.
190
+ """
191
+ if not isinstance(manifest, dict):
192
+ return []
193
+ if manifest.get("schema_version") != SYSTEM_MANIFEST_SCHEMA_VERSION:
194
+ return []
195
+ raw = manifest.get("slices")
196
+ if not isinstance(raw, list):
197
+ return []
198
+ slices = []
199
+ for entry in raw:
200
+ if not isinstance(entry, dict):
201
+ return []
202
+ if not isinstance(entry.get("core_id"), str) or not isinstance(entry.get("os"), str):
203
+ return []
204
+ slices.append(entry)
205
+ return slices
206
+
207
+
208
+ def _core_not_in_manifest_message(core: str, slices: list[dict[str, Any]]) -> str:
209
+ """tan-cli#462 review: names the cores this build DID produce, the
210
+ obvious next question once told the one passed does not exist. `slices`
211
+ is the manifest's own core list at the point this fires (before `--core`
212
+ narrows it to nothing), so it is never empty here."""
213
+ cores = ", ".join(dict.fromkeys(s["core_id"] for s in slices))
214
+ return (
215
+ f"--target-kind was not given, and --core {core} does not match any "
216
+ f"slice in this project's build/system-manifest.yaml (its cores: {cores}); "
217
+ "pass --target-kind explicitly, or a --core value this project's build "
218
+ "actually produced."
219
+ )
220
+
221
+
222
+ def explicit_core_unknown_message(core: str, slices: list[dict[str, Any]]) -> str:
223
+ """tan-cli#489 (5): the `--target-kind`-EXPLICIT counterpart of
224
+ [`_core_not_in_manifest_message`] above. An explicit `--target-kind`
225
+ bypasses [`infer_target_kind`] (and ITS OWN `--core`-vs-manifest guard,
226
+ the function above) entirely, so `debug_config_cmd.py` calls this
227
+ directly once it has resolved a real target/server of its own to report,
228
+ instead of the placeholder pair `_core_not_in_manifest_message`'s caller
229
+ reports. Not private (unlike its sibling): the command module is the
230
+ caller here, so pure logic stays in `tan.core` per this file's own
231
+ convention rather than duplicating the "list the cores this build
232
+ produced" formatting there."""
233
+ cores = ", ".join(dict.fromkeys(s["core_id"] for s in slices))
234
+ return (
235
+ f"--core {core} does not match any slice in this project's own "
236
+ f"build/system-manifest.yaml (its cores: {cores}); pass a --core "
237
+ "value this project's build actually produced."
238
+ )
239
+
240
+
241
+ def multi_core_without_core_message(target: str, slices: list[dict[str, Any]]) -> str | None:
242
+ """tan-cli#1488: a message when `--core` is omitted but this build has more
243
+ than one hardware slice of `target`'s class, so picking "the first" would
244
+ program a core the caller may not have meant; `None` otherwise. Mirrors
245
+ `tan probe` / `tan flash`, which refuse the same case."""
246
+ manifest_os = MANIFEST_OS_BY_TARGET.get(target)
247
+ if manifest_os is None or target == NATIVE_HOST:
248
+ return None
249
+ cores = list(
250
+ dict.fromkeys(
251
+ s["core_id"]
252
+ for s in slices
253
+ if s.get("os") == manifest_os
254
+ and isinstance(s.get("core_id"), str)
255
+ and not is_native_sim_board(s.get("board"))
256
+ )
257
+ )
258
+ if len(cores) < 2:
259
+ return None
260
+ return (
261
+ f"--core was not given, and this project's build/system-manifest.yaml has "
262
+ f"more than one {target} core ({', '.join(cores)}); the launch configuration "
263
+ "programs one core's image, so pass --core to say which one to debug."
264
+ )
265
+
266
+
267
+ def _ambiguous_target_classes_message(targets: set[str]) -> str:
268
+ # The mapped --target-kind SPELLINGS (`zephyr-mcu`), never the raw
269
+ # manifest `os` value (`zephyr`) -- pasting the bare `os` value into
270
+ # --target-kind fails with "Unsupported --target-kind 'zephyr'".
271
+ classes = ", ".join(sorted(targets))
272
+ return (
273
+ "--target-kind was not given, and this project's "
274
+ f"build/system-manifest.yaml names more than one target class ({classes}); "
275
+ "pass --target-kind (and --core, on a mixed-core board) to say which "
276
+ "one to debug."
277
+ )
278
+
279
+
280
+ def _no_debuggable_class_message(oses: set[Any]) -> str:
281
+ # Distinct from `_ambiguous_target_classes_message`: a single unrecognised
282
+ # `os` (e.g. a `linux` slice) is not "more than one" of anything.
283
+ unmapped = ", ".join(sorted(oses))
284
+ return (
285
+ "--target-kind was not given, and this project's "
286
+ f"build/system-manifest.yaml names no debuggable target class for os: "
287
+ f"{unmapped}; pass --target-kind explicitly."
288
+ )
289
+
290
+
291
+ def _pre_build_hardware_message(som_sku: str) -> str:
292
+ return (
293
+ f"--target-kind was not given, and board.yaml declares som.sku: {som_sku} (a "
294
+ "real hardware project), but no build/system-manifest.yaml exists yet to say "
295
+ "which target class it builds. Run `tan build` first, or pass --target-kind "
296
+ "explicitly."
297
+ )
298
+
299
+
300
+ def infer_target_kind(
301
+ manifest: Any, core: str | None, som_sku: str | None
302
+ ) -> tuple[str | None, str | None, str | None]:
303
+ """tan-cli#456: the default ``--target-kind`` when the flag is omitted, so
304
+ a hardware project never silently gets a native-host draft its build
305
+ cannot produce. Shared, not duplicated per command: ``debug_config_cmd``
306
+ and (once routed through this rather than its own bare
307
+ ``parse_target_kind(None)``) ``support_bundle_cmd`` face the identical
308
+ question. Pure -- ``manifest``/``som_sku`` are already read; no file IO.
309
+
310
+ Returns ``(target, None, None)`` once confidently derived; ``(None, None,
311
+ None)`` with no signal at all (native-host survives, the pre-#456
312
+ default); ``(None, code, reason)`` on a refusal. ``code`` is a
313
+ `debug-config.<code>` suffix, tan-cli#462's split of the old blanket
314
+ ``internal-failure`` into one code per CALLER-fixable precondition --
315
+ pre-build hardware, a bad ``--core``, ambiguous target classes, or none
316
+ debuggable at all -- so ``code`` is never ``None`` once ``reason`` is.
317
+
318
+ ``--core`` filters the WHOLE slice list first, before native_sim is
319
+ excluded -- never only the hardware slices: that was the review-round bug,
320
+ where ``--core <the native_sim slice's own core id>`` on a mixed manifest
321
+ silently fell back to the OTHER core's class and wrote a J-Link session
322
+ pointed at the native_sim binary. Hardware also outvotes a CO-BUILT
323
+ native_sim slice whenever ``--core`` is absent -- the #83
324
+ ``_select_slice`` NATIVE_HOST arm needs an explicit ``--target-kind
325
+ native-host`` (or ``--core`` naming the native_sim slice) to reach.
326
+ """
327
+ all_slices = manifest_slices(manifest)
328
+ if core is not None and all_slices:
329
+ matched = [s for s in all_slices if s.get("core_id") == core]
330
+ if not matched:
331
+ return None, "core-unknown", _core_not_in_manifest_message(core, all_slices)
332
+ all_slices = matched
333
+
334
+ hw_slices = [s for s in all_slices if not is_native_sim_board(s.get("board"))]
335
+ if hw_slices:
336
+ by_os = {v: k for k, v in MANIFEST_OS_BY_TARGET.items()}
337
+ oses = {s.get("os") for s in hw_slices}
338
+ targets = {by_os[o] for o in oses if o in by_os}
339
+ if len(targets) == 1:
340
+ return next(iter(targets)), None, None
341
+ if targets:
342
+ return None, "target-kind-ambiguous", _ambiguous_target_classes_message(targets)
343
+ return None, "no-debuggable-target-class", _no_debuggable_class_message(oses)
344
+ if all_slices:
345
+ return NATIVE_HOST, None, None # every (matching) slice built is native_sim -- a real default.
346
+
347
+ if isinstance(som_sku, str) and som_sku != "":
348
+ return None, "build-manifest-missing", _pre_build_hardware_message(som_sku)
349
+ return None, None, None
350
+
351
+
352
+ #: The v0.3.1 default ``preLaunchTask``, restored per tan-cli#138. Keyed by
353
+ #: TARGET ALONE, never by server: ``crates/tan-core/src/debug_launch.rs``
354
+ #: hardcoded the identical literal in every one of ``ZephyrMcu``'s and
355
+ #: ``BaremetalMcu``'s server-branched arms before tan-cli#85 made the key
356
+ #: opt-in, so J-Link/OpenOCD/pyOCD all shared one string per target, not one
357
+ #: each. ``--pre-launch-task`` overrides a target's default; an EXPLICIT empty
358
+ #: string opts out of a ``preLaunchTask`` key entirely -- see
359
+ #: :func:`create_launch_draft`.
360
+ #:
361
+ #: **``YOCTO_USERSPACE`` is deliberately absent** -- three of the four targets
362
+ #: get a default, not four. v0.3.1 did hardcode
363
+ #: ``"alp: deploy and start gdbserver"`` for it, but restoring THAT one would
364
+ #: re-break what alp-sdk-vscode#406 deliberately fixed. That repo's
365
+ #: ``preLaunchTaskFor`` (``src/tasks/service.ts``) maps only the three build
366
+ #: kinds, and states why verbatim:
367
+ #:
368
+ #: yocto-userspace deliberately gets NOTHING. The only task registered for
369
+ #: it is the "deploy and start gdbserver" placeholder, which exits 1 by
370
+ #: design (``vscodeAdapter.ts``) because the extension cannot deploy or
371
+ #: start a remote gdbserver. Naming it would put VS Code's "the
372
+ #: preLaunchTask terminated with exit code 1 -- Debug Anyway / Show
373
+ #: Errors" dialog in front of EVERY F5, including one where the customer
374
+ #: has already copied the binary across, started gdbserver by hand and
375
+ #: filled in ``miDebuggerServerAddress`` -- the setup that works.
376
+ #:
377
+ #: So tan-cli#138 and tan-cli#321 pull opposite ways here, and only three of
378
+ #: the four labels are safe to restore. For the build kinds the extension DOES
379
+ #: register a working task, and omitting the key is precisely what left its
380
+ #: provider contribution dead. For yocto-userspace the only task that exists
381
+ #: fails by design, so naming it would degrade the one workflow that currently
382
+ #: succeeds. A user with their own deploy task still passes
383
+ #: ``--pre-launch-task`` explicitly.
384
+ DEFAULT_PRE_LAUNCH_TASK: dict[str, str] = {
385
+ ZEPHYR_MCU: "alp: build active target",
386
+ BAREMETAL_MCU: "alp: build baremetal target",
387
+ NATIVE_HOST: "alp: build native_sim target",
388
+ }
389
+
390
+
391
+ def create_launch_draft(
392
+ target: str, server: str, pre_launch_task: str | None
393
+ ) -> dict[str, Any]:
394
+ """The VS Code launch configuration draft for a target/server (TS
395
+ ``createDebugProfile`` -> ``debugProfileToLaunchDraft``).
396
+
397
+ ``pre_launch_task`` has three states, not two (tan-cli#138):
398
+
399
+ * ``None`` -- the flag was not passed. Takes this target's restored
400
+ v0.3.1 default from :data:`DEFAULT_PRE_LAUNCH_TASK`. Every draft used to
401
+ carry this hardcoded string unconditionally; tan-cli#85 made the key
402
+ opt-in because **nothing in any of the three repos defined the task
403
+ it named** -- no ``tasks.json``, no ``TaskProvider`` registration --
404
+ and VS Code resolves ``preLaunchTask`` before launching, fails to find
405
+ the task, and aborts pre-launch, so the session never started: a
406
+ launch.json that reads perfectly and cannot run. alp-sdk-vscode has
407
+ since registered the THREE build labels as real, working tasks, so the
408
+ default is restored for those three targets. The fourth label exists
409
+ only as a placeholder that exits 1 by design, and
410
+ ``preLaunchTaskFor`` maps three of four kinds for that reason -- see
411
+ :data:`DEFAULT_PRE_LAUNCH_TASK` for the full quotation. A target with
412
+ no entry there behaves exactly as it did before this restoration.
413
+ * ``""`` (an explicitly empty string) -- opts OUT of a ``preLaunchTask``
414
+ key entirely, even though a default now exists for this target. The
415
+ only way left to reach the trailing ``del`` below.
416
+ * Anything else -- emitted verbatim, overriding the default. Unchanged
417
+ from before this restoration.
418
+ """
419
+ if not is_server_supported_for_target(target, server):
420
+ raise DebugConfigError(
421
+ f"Unsupported debug backend '{server}' for target '{target}'."
422
+ )
423
+ label = _SERVER_LABELS[server]
424
+
425
+ if pre_launch_task is None:
426
+ pre_launch_task = DEFAULT_PRE_LAUNCH_TASK.get(target)
427
+ elif pre_launch_task == "":
428
+ pre_launch_task = None
429
+
430
+ if target == ZEPHYR_MCU:
431
+ name = f"Alp: Zephyr Debug ({label})"
432
+ common = {
433
+ "name": name,
434
+ "type": "cortex-debug",
435
+ "request": "launch",
436
+ "cwd": "${workspaceFolder}",
437
+ "executable": "${workspaceFolder}/build/app/zephyr/zephyr.elf",
438
+ # tan-cli#945: stated explicitly rather than left to
439
+ # `marus25.cortex-debug`'s own undocumented-on-the-wire schema
440
+ # default ("if this property does not exist, then the executable
441
+ # is used to program the device") -- kept identical to
442
+ # `executable` by `apply_launch_resolution` below, since this
443
+ # draft has no attach-only shape for a caller to opt into yet.
444
+ "loadFiles": ["${workspaceFolder}/build/app/zephyr/zephyr.elf"],
445
+ "runToEntryPoint": "main",
446
+ "preLaunchTask": pre_launch_task,
447
+ "svdFile": "<resolved-svd>",
448
+ "svdPath": "<resolved-svd>",
449
+ }
450
+ if server == OPENOCD:
451
+ draft = {
452
+ **common,
453
+ "servertype": "openocd",
454
+ "configFiles": ["<resolved-openocd-board-cfg>"],
455
+ }
456
+ elif server == PYOCD:
457
+ draft = {**common, "servertype": "pyocd", "targetId": "<resolved-target-id>"}
458
+ else:
459
+ draft = {
460
+ **common,
461
+ "servertype": "jlink",
462
+ "device": "<resolved-device>",
463
+ "interface": "swd",
464
+ }
465
+ elif target == BAREMETAL_MCU:
466
+ name = f"Alp: Baremetal Debug ({label})"
467
+ # tan-cli#139: this used to be ONE un-branched object regardless of
468
+ # `server`, so OpenOCD and pyOCD got `device`/`interface` (a J-Link-only
469
+ # pair `apply_launch_resolution` never fills for them) and neither got
470
+ # the `configFiles`/`targetId` key its own resolution computes --
471
+ # `apply_launch_resolution` only replaces a key the draft already
472
+ # carries, so OpenOCD shipped a resolved `serverpath`/`searchDir` with NO
473
+ # `configFiles` to load, and pyOCD had no target to select.
474
+ head = {
475
+ "name": name,
476
+ "type": "cortex-debug",
477
+ "request": "launch",
478
+ "servertype": server,
479
+ "cwd": "${workspaceFolder}",
480
+ "executable": "${workspaceFolder}/build/baremetal/app.elf",
481
+ # tan-cli#945: see the ZEPHYR_MCU branch above for why this is
482
+ # stated explicitly rather than left to the adapter's default.
483
+ "loadFiles": ["${workspaceFolder}/build/baremetal/app.elf"],
484
+ }
485
+ if server == OPENOCD:
486
+ draft = {
487
+ **head,
488
+ "preLaunchTask": pre_launch_task,
489
+ "svdFile": "<resolved-svd>",
490
+ "svdPath": "<resolved-svd>",
491
+ "configFiles": ["<resolved-openocd-board-cfg>"],
492
+ }
493
+ elif server == PYOCD:
494
+ draft = {
495
+ **head,
496
+ "preLaunchTask": pre_launch_task,
497
+ "svdFile": "<resolved-svd>",
498
+ "svdPath": "<resolved-svd>",
499
+ "targetId": "<resolved-target-id>",
500
+ }
501
+ else:
502
+ draft = {
503
+ **head,
504
+ "device": "<resolved-device>",
505
+ "interface": "swd",
506
+ "preLaunchTask": pre_launch_task,
507
+ "svdFile": "<resolved-svd>",
508
+ "svdPath": "<resolved-svd>",
509
+ }
510
+ elif target == YOCTO_USERSPACE:
511
+ draft = {
512
+ "name": "Alp: Yocto Remote Debug",
513
+ "type": "cppdbg",
514
+ "request": "launch",
515
+ "program": "${workspaceFolder}/build/yocto/app",
516
+ "cwd": "${workspaceFolder}",
517
+ "MIMode": "gdb",
518
+ "miDebuggerServerAddress": "<host>:<port>",
519
+ "miDebuggerPath": "<resolved-gdb>",
520
+ "setupCommands": [{"text": "-enable-pretty-printing"}],
521
+ "preLaunchTask": pre_launch_task,
522
+ }
523
+ else:
524
+ draft = {
525
+ "name": "Alp: Native Sim Debug",
526
+ # `lldb`, not `codelldb`, because CodeLLDB's own manifest says so:
527
+ # `vadimcn.vscode-lldb` v1.12.2 declares
528
+ # `contributes.debuggers[0].type = "lldb"`. `codelldb` is the
529
+ # extension's marketplace NAME; no extension registers it as a debug
530
+ # type, so VS Code refused every session outright with `Configured
531
+ # debug type 'codelldb' is not supported.` (#104). native_sim is the
532
+ # only target reachable with no probe and no board -- the first
533
+ # debugging experience a customer has.
534
+ "type": "lldb",
535
+ "request": "launch",
536
+ "program": "${workspaceFolder}/build/native_sim/zephyr/zephyr.exe",
537
+ "cwd": "${workspaceFolder}",
538
+ "preLaunchTask": pre_launch_task,
539
+ }
540
+
541
+ # A literal `"preLaunchTask": null` is worse than no key at all (VS Code's
542
+ # schema rejects it). Built-then-deleted, not conditionally inserted,
543
+ # because the key sits mid-order and the order is contract.
544
+ if draft.get("preLaunchTask") is None:
545
+ del draft["preLaunchTask"]
546
+ return draft
547
+
548
+
549
+ @dataclass
550
+ class LaunchResolution:
551
+ """What a real build knows about itself, filled in over the draft's
552
+ ``<resolved-...>`` placeholders (#66). Every field is optional: pre-build, or
553
+ against a Zephyr that reshaped ``runners.yaml``, nothing resolves and the
554
+ draft keeps the placeholder it has always had."""
555
+
556
+ #: The slice's real ELF -- per-core, not the fixed `build/app/...` guess.
557
+ executable: str | None = None
558
+ #: J-Link device name (`args.jlink --device`).
559
+ device: str | None = None
560
+ #: The toolchain GDB the build was made against (`config.gdb`).
561
+ gdb_path: str | None = None
562
+ #: The OpenOCD binary the build resolved (`config.openocd`).
563
+ server_path: str | None = None
564
+ #: OpenOCD script search directories (`config.openocd_search`).
565
+ search_dirs: list[str] = field(default_factory=list)
566
+ #: OpenOCD config files (`args.openocd --config`, all of them).
567
+ config_files: list[str] = field(default_factory=list)
568
+ #: pyOCD target id (`args.pyocd --target`).
569
+ target_id: str | None = None
570
+ #: SVD path. Produced ONLY by `tan debug-config --svd` (tan-cli#197): the
571
+ #: SDK ships no SVD file, and alp-sdk#948's vendor-redistribution licence
572
+ #: question may mean it never does.
573
+ svd: str | None = None
574
+ #: `host:port` for a yocto-userspace draft's `miDebuggerServerAddress`.
575
+ #: Produced ONLY by `tan debug-config --gdbserver-address` (tan-cli#321):
576
+ #: this is a runtime property of the DEPLOYED board -- which host it is
577
+ #: reachable at and which port its gdbserver is listening on -- which no
578
+ #: build, and no SDK-published metadata, can ever resolve. `None` unless
579
+ #: the caller passed one.
580
+ gdbserver_address: str | None = None
581
+ #: J-Link probe serial (`flash_args.jlink_serial` of the selected slice) --
582
+ #: the probe `tan flash` would select. Pinned as cortex-debug's
583
+ #: `serialNumber` so F5 cannot open a different J-Link when several are
584
+ #: attached (tan-cli#1488). `None` leaves the probe unpinned.
585
+ probe_serial: str | None = None
586
+
587
+
588
+ def fill_debug_probe_identity_gaps(
589
+ resolution: LaunchResolution,
590
+ core_id: str | None,
591
+ jlink_device: dict[str, str],
592
+ pyocd_target: str | None,
593
+ openocd_config: str | None,
594
+ ) -> None:
595
+ """Fill ``resolution``'s ``device``/``target_id``/``config_files`` gaps from
596
+ the SDK's published per-variant debug-probe identity (``variants[].debug``,
597
+ alp-sdk#987 / alp-sdk#1026) -- the same three fields a real build's
598
+ ``runners.yaml`` resolves, sourced instead from the SoC-JSON metadata that
599
+ exists whether or not the project has been built yet. Port of
600
+ ``tan_core::debug_launch::fill_debug_probe_identity_gaps``.
601
+
602
+ **Fill-the-gap only, never override**: each field is written ONLY when
603
+ ``resolution`` does not already carry a value for it. A real build's own
604
+ resolution (Zephyr's own ``runners.yaml``, generated for THIS board) is
605
+ strictly more specific than the SDK's generic per-variant identity and
606
+ always wins where both exist.
607
+
608
+ **No fabrication**: ``jlink_device`` is keyed by core id, so ``device``
609
+ resolves only when ``core_id`` is not ``None`` AND that exact key is
610
+ present -- there is no "the only entry" or "the first entry" guess.
611
+ ``target_id``/``config_files`` resolve only when the corresponding SDK key
612
+ is itself present; an absent ``openocd_config`` leaves ``config_files``
613
+ empty, which is the correct published "unknown", not a bug.
614
+ """
615
+ if resolution.device is None:
616
+ resolution.device = jlink_device.get(core_id) if core_id is not None else None
617
+ if resolution.target_id is None:
618
+ resolution.target_id = pyocd_target
619
+ if not resolution.config_files and openocd_config is not None:
620
+ resolution.config_files = [openocd_config]
621
+
622
+
623
+ def apply_launch_resolution(draft: dict[str, Any], resolution: LaunchResolution) -> None:
624
+ """Overwrite a draft's placeholders with what ``resolution`` knows, in place.
625
+
626
+ Only keys the draft already carries are replaced, so a target that never had
627
+ a ``device`` does not grow one; the two genuinely new keys (``gdbPath``, and
628
+ ``serverpath``/``searchDir``) are inserted only when they resolved and only
629
+ for the adapter that understands them.
630
+
631
+ The ``svdFile``/``svdPath`` placeholders are REMOVED when no SVD resolved. A
632
+ missing key costs the peripheral view; a path that does not exist makes
633
+ cortex-debug fail on start, which is strictly worse than not offering the
634
+ view (alp-sdk#948).
635
+ """
636
+ is_cortex = draft.get("type") == "cortex-debug"
637
+
638
+ # `executable` (cortex-debug) / `program` (cppdbg, lldb) name the same thing
639
+ # under different adapters; replace whichever this draft uses.
640
+ if resolution.executable is not None:
641
+ for key in ("executable", "program"):
642
+ if key in draft:
643
+ draft[key] = resolution.executable
644
+ # tan-cli#945: `loadFiles` names the SAME artefact as `executable` --
645
+ # a real build's own resolved path must replace it there too, or a
646
+ # per-core slice's ELF and the file cortex-debug actually programs
647
+ # would silently diverge the moment `_resolve_from_build` finds one.
648
+ if "loadFiles" in draft:
649
+ draft["loadFiles"] = [resolution.executable]
650
+ if resolution.device is not None and "device" in draft:
651
+ draft["device"] = resolution.device
652
+ if resolution.target_id is not None and "targetId" in draft:
653
+ draft["targetId"] = resolution.target_id
654
+ if resolution.config_files and "configFiles" in draft:
655
+ draft["configFiles"] = list(resolution.config_files)
656
+ if resolution.gdbserver_address is not None and "miDebuggerServerAddress" in draft:
657
+ # tan-cli#321: the ONLY source of this field's resolution -- see
658
+ # `LaunchResolution.gdbserver_address`'s own docstring for why nothing
659
+ # else (build or SDK metadata) can ever fill it.
660
+ draft["miDebuggerServerAddress"] = resolution.gdbserver_address
661
+ if resolution.gdb_path is not None:
662
+ # cppdbg spells it `miDebuggerPath` and already carries the key;
663
+ # cortex-debug's `gdbPath` is additive.
664
+ if "miDebuggerPath" in draft:
665
+ draft["miDebuggerPath"] = resolution.gdb_path
666
+ elif is_cortex:
667
+ draft["gdbPath"] = resolution.gdb_path
668
+ if is_cortex and draft.get("servertype") == JLINK and resolution.probe_serial is not None:
669
+ draft["serialNumber"] = resolution.probe_serial
670
+ if is_cortex and draft.get("servertype") == OPENOCD:
671
+ if resolution.server_path is not None:
672
+ draft["serverpath"] = resolution.server_path
673
+ if resolution.search_dirs:
674
+ draft["searchDir"] = list(resolution.search_dirs)
675
+ if resolution.svd is not None:
676
+ for key in ("svdFile", "svdPath"):
677
+ if key in draft:
678
+ draft[key] = resolution.svd
679
+ else:
680
+ for key in ("svdFile", "svdPath"):
681
+ if draft.get(key) == "<resolved-svd>":
682
+ del draft[key]
683
+
684
+
685
+ def sdk_identity_overwrites(
686
+ existing_content: str | None,
687
+ draft: dict[str, Any],
688
+ filled_fields: list[str],
689
+ provenance: LaunchProvenance | None = None,
690
+ ) -> list[tuple[str, str, str]]:
691
+ """Whether writing ``draft`` (already ``apply_launch_resolution``'d) over
692
+ ``existing_content`` would REPLACE an already-concrete value on any of
693
+ ``filled_fields`` -- the exact launch-configuration JSON keys a caller's
694
+ own resolution just populated from a source that is not a real build. Port
695
+ of ``tan_core::debug_launch::sdk_identity_overwrites``.
696
+
697
+ alp-sdk#1026 review finding #1: [`_merge_value`]'s only protection against
698
+ clobbering a customer's hand-filled value is that the INCOMING value is
699
+ unresolved (a ``<...>`` placeholder) -- by design, since a value resolved
700
+ from a real build is supposed to overwrite unconditionally (see
701
+ [`_merge_configuration`]'s doc comment). The SDK's published debug-probe
702
+ identity (alp-sdk#987) resolves a device/target id/config PRE-build, so
703
+ its values are never placeholders either -- reaching that same
704
+ unconditional-overwrite branch for a source with materially less
705
+ confidence than an actual build, silently. This function does not change
706
+ that overwrite (a customer's real, in-repo launch.json is still allowed to
707
+ go stale the same way it always could against a real build), it only lets
708
+ the caller SAY so, the same way ``LaunchJsonWritePlan.comments_dropped``
709
+ discloses a lossy write instead of hiding it behind ``ok: true``.
710
+
711
+ Returns ``(field, existing, incoming)`` for each field this write is about
712
+ to change from a concrete existing value to a DIFFERENT concrete one;
713
+ empty when nothing concrete would be lost (no matching entry, the existing
714
+ field is itself unresolved/absent, or the two values already agree).
715
+ Matches the SAME entry [`create_launch_json_write_plan`] would merge into
716
+ (current name, else its legacy counterpart) so this can never flag a field
717
+ on an unrelated configuration.
718
+
719
+ tan-cli#518: for a LIST field (today only ``configFiles`` -- ``device``/
720
+ ``targetId`` are scalars, unaffected by this paragraph and still
721
+ unconditionally disclosed as before), the real merge no longer always
722
+ overwrites an unmatched existing entry -- [`_merge_list_by_identity`]'s
723
+ positional fallback now requires ``provenance`` to confirm the entry it
724
+ is about to reuse is tan's OWN prior output (see that function's
725
+ docstring). Disclosing "replaced" when the actual write instead APPENDED
726
+ the new entry and left the old one exactly where it was -- the safe
727
+ outcome the provenance gate exists to produce -- would be a false alarm
728
+ worse than useless: it would tell a customer to go manually restore a
729
+ value that was never touched. So this mirrors the real merge decision via
730
+ [`_merge_list_field`] before flagging a list field, and only flags it when
731
+ that merge would actually have dropped the existing concrete value.
732
+ """
733
+ out: list[tuple[str, str, str]] = []
734
+ if not filled_fields:
735
+ return out
736
+ if provenance is None:
737
+ provenance = launch_provenance.empty()
738
+ try:
739
+ name = _configuration_name(draft)
740
+ document = _parse_launch_json_or_default(existing_content)
741
+ except DebugConfigError:
742
+ return out
743
+ configs = document.get("configurations")
744
+ if not isinstance(configs, list):
745
+ return out
746
+ existing_entry = _matching_existing_entry(configs, name)
747
+ if existing_entry is None:
748
+ return out
749
+ for field in filled_fields:
750
+ if field not in existing_entry or field not in draft:
751
+ continue
752
+ existing_val = existing_entry[field]
753
+ incoming_val = draft[field]
754
+ if not _value_is_concrete(existing_val) or existing_val == incoming_val:
755
+ continue
756
+ if isinstance(existing_val, list) and isinstance(incoming_val, list):
757
+ hashes = provenance.hashes_for(name, field)
758
+ merged, _owned = _merge_list_field(list(existing_val), list(incoming_val), hashes)
759
+ if not _list_lost_a_concrete_entry(existing_val, merged):
760
+ continue
761
+ out.append((field, _display_value(existing_val), _display_value(incoming_val)))
762
+ return out
763
+
764
+
765
+ def sdk_identity_stranded_appends(
766
+ existing_content: str | None,
767
+ draft: dict[str, Any],
768
+ filled_fields: list[str],
769
+ provenance: LaunchProvenance | None = None,
770
+ ) -> list[tuple[str, str, str]]:
771
+ """tan-cli#518 review finding #2, the counterpart [`sdk_identity_
772
+ overwrites`] deliberately does NOT cover: whether writing ``draft`` over
773
+ ``existing_content`` would leave one of ``filled_fields``' existing LIST
774
+ values stranded in the file -- APPENDED alongside the incoming
775
+ SDK-resolved value rather than replaced -- because ``provenance`` could
776
+ not prove that existing value was tan's own prior output.
777
+ [`sdk_identity_overwrites`] treats this exact shape as "nothing concrete
778
+ was lost" and stays silent about it, correctly for what THAT code
779
+ discloses (there is nothing to tell the customer to go "restore"), but
780
+ that left the customer with two ``configFiles`` entries -- two board
781
+ ``.cfg``s on the same TAP, same failure class
782
+ ``test_a_resolved_replacement_overwrites_the_previous_one_instead_of_
783
+ accumulating`` names -- and `issues: []`, no hint tan chose to leave one
784
+ of them behind (tan-cli#982 review).
785
+
786
+ Returns ``(field, existing, incoming)`` for each list field where this
787
+ write's real merge (mirrored via [`_merge_list_field`], same as
788
+ [`sdk_identity_overwrites`] does) would APPEND rather than replace: every
789
+ concrete entry ``existing`` already held survives AND the merge result
790
+ differs from ``existing`` (something new landed beside it). A scalar
791
+ field never reaches this -- only a list can be "appended to" instead of
792
+ overwritten. An unrelated configuration, a field absent from either side,
793
+ or a merge that changes nothing at all return nothing, using the SAME
794
+ entry-discovery rule [`sdk_identity_overwrites`] uses (current name, else
795
+ its legacy counterpart) so both functions can never disagree about which
796
+ entry this write is actually touching.
797
+ """
798
+ out: list[tuple[str, str, str]] = []
799
+ if not filled_fields:
800
+ return out
801
+ if provenance is None:
802
+ provenance = launch_provenance.empty()
803
+ try:
804
+ name = _configuration_name(draft)
805
+ document = _parse_launch_json_or_default(existing_content)
806
+ except DebugConfigError:
807
+ return out
808
+ configs = document.get("configurations")
809
+ if not isinstance(configs, list):
810
+ return out
811
+ existing_entry = _matching_existing_entry(configs, name)
812
+ if existing_entry is None:
813
+ return out
814
+ for field in filled_fields:
815
+ if field not in existing_entry or field not in draft:
816
+ continue
817
+ existing_val = existing_entry[field]
818
+ incoming_val = draft[field]
819
+ if not (isinstance(existing_val, list) and isinstance(incoming_val, list)):
820
+ continue
821
+ if not _value_is_concrete(existing_val) or existing_val == incoming_val:
822
+ continue
823
+ hashes = provenance.hashes_for(name, field)
824
+ merged, _owned = _merge_list_field(list(existing_val), list(incoming_val), hashes)
825
+ if merged == existing_val or _list_lost_a_concrete_entry(existing_val, merged):
826
+ continue
827
+ out.append((field, _display_value(existing_val), _display_value(incoming_val)))
828
+ return out
829
+
830
+
831
+ def load_files_preserved(
832
+ existing_content: str | None,
833
+ draft: dict[str, Any],
834
+ provenance: LaunchProvenance | None = None,
835
+ ) -> tuple[str, str] | None:
836
+ """tan-cli#1020 review: whether writing ``draft`` over ``existing_content``
837
+ will PROTECT an existing ``loadFiles`` value this run cannot prove is
838
+ tan's own prior output (see [`_merge_load_files`]) -- i.e. leave a
839
+ customer's hand-authored artefact list, an explicit attach-only ``[]``
840
+ included, exactly as it already was instead of the fresh resolution
841
+ ``draft`` itself carries. Mirrors the SAME merge decision the real write
842
+ (``create_launch_json_write_plan``, via ``_merge_configuration`` ->
843
+ ``_merge_load_files``) makes, the same way [`sdk_identity_overwrites`] /
844
+ [`sdk_identity_stranded_appends`] mirror theirs, so a caller can compute
845
+ it before a write happens (or without writing at all).
846
+
847
+ Returns ``(existing_display, incoming_display)`` when this write
848
+ protects a DIFFERING existing value -- the disclosure
849
+ ``debug-config.load-files-preserved`` names, the ``loadFiles`` sibling
850
+ of ``debug-config.sdk-identity-appended`` -- and ``None`` when there is
851
+ nothing to disclose: no ``loadFiles`` key on either side, the two
852
+ already agree, or this run CAN prove the existing value is its own (a
853
+ real overwrite happens instead, the same "a value resolved from a real
854
+ build overwrites unconditionally, by design" case every other field in
855
+ this module already leaves undisclosed).
856
+ """
857
+ if "loadFiles" not in draft:
858
+ return None
859
+ if provenance is None:
860
+ provenance = launch_provenance.empty()
861
+ try:
862
+ name = _configuration_name(draft)
863
+ document = _parse_launch_json_or_default(existing_content)
864
+ except DebugConfigError:
865
+ return None
866
+ configs = document.get("configurations")
867
+ if not isinstance(configs, list):
868
+ return None
869
+ existing_entry = _matching_existing_entry(configs, name)
870
+ if existing_entry is None:
871
+ return None
872
+ existing_val = existing_entry.get("loadFiles")
873
+ incoming_val = draft["loadFiles"]
874
+ if not isinstance(existing_val, list) or existing_val == incoming_val:
875
+ return None
876
+ hashes = provenance.hashes_for(name, "loadFiles")
877
+ if _load_files_is_tan_owned(existing_val, hashes):
878
+ return None
879
+ return (_display_value(existing_val), _display_value(incoming_val))
880
+
881
+
882
+ def _matching_existing_entry(configs: list[Any], name: str) -> dict[str, Any] | None:
883
+ """The SAME launch-configuration entry [`create_launch_json_write_plan`]
884
+ would merge ``draft`` into for name ``name``: an exact-name hit, else its
885
+ one legacy ``"ALP: ..."`` counterpart, else `None`. Shared by
886
+ [`sdk_identity_overwrites`] and [`sdk_identity_stranded_appends`] so both
887
+ compute "which entry is this write actually touching" identically, and
888
+ only once."""
889
+ existing_entry = next(
890
+ (c for c in configs if isinstance(c, dict) and c.get("name") == name), None
891
+ )
892
+ if existing_entry is not None:
893
+ return existing_entry
894
+ legacy = _legacy_name(name)
895
+ if legacy is None:
896
+ return None
897
+ return next(
898
+ (c for c in configs if isinstance(c, dict) and c.get("name") == legacy), None
899
+ )
900
+
901
+
902
+ def _list_lost_a_concrete_entry(existing_list: list[Any], merged_list: list[Any]) -> bool:
903
+ """Whether ``merged_list`` (a real [`_merge_list_field`] result) DROPPED a
904
+ concrete element ``existing_list`` held -- the list-field analogue of a
905
+ scalar's plain ``!=``, honouring the SAME provenance gate the actual merge
906
+ applied (tan-cli#518): an unmatched existing element that provenance
907
+ protected is APPENDED alongside the new one, still present in
908
+ ``merged_list``, and is therefore not "lost" even though the two lists as
909
+ a WHOLE differ."""
910
+ return any(_value_is_concrete(v) and v not in merged_list for v in existing_list)
911
+
912
+
913
+ def _value_is_concrete(value: Any) -> bool:
914
+ """Whether ``value`` is a real, non-placeholder value a customer could
915
+ have hand-filled -- mirrors the exact condition [`_merge_value`] treats as
916
+ worth protecting (a string that isn't a ``<...>`` token; an array that
917
+ isn't empty and isn't entirely placeholders)."""
918
+ if isinstance(value, str):
919
+ return not is_unresolved_placeholder(value)
920
+ if isinstance(value, list):
921
+ return len(value) > 0 and not all(_is_unresolved(v) for v in value)
922
+ return False
923
+
924
+
925
+ def _display_value(value: Any) -> str:
926
+ """Render a JSON value for an issue message: a string unquoted, anything
927
+ else (an array, for ``configFiles``) via its normal compact JSON
928
+ rendering -- matching serde_json::Value's ``Display`` impl, which
929
+ ``sdk_identity_overwrites`` mirrors."""
930
+ if isinstance(value, str):
931
+ return value
932
+ return json.dumps(value, separators=(",", ":"))
933
+
934
+
935
+ def launch_preview_notes() -> list[str]:
936
+ """The static advisory notes attached to a launch preview (TS
937
+ ``createLaunchPreview``)."""
938
+ return [
939
+ "This is a draft launch configuration generated by tan.",
940
+ "Placeholder fields such as <resolved-device> still need project-specific resolution.",
941
+ "The long-term target is to resolve these values from the shared debug model.",
942
+ ]
943
+
944
+
945
+ def launch_preview_document(draft: dict[str, Any]) -> dict[str, Any]:
946
+ """The launch.json-shaped preview document: ``{version, configurations:[draft]}``."""
947
+ return {"version": "0.2.0", "configurations": [draft]}
948
+
949
+
950
+ def is_unresolved_placeholder(value: str) -> bool:
951
+ """Whether a string is one of OUR "nobody filled this in yet" markers.
952
+
953
+ The two brace styles in a launch configuration mean opposite things, and
954
+ that is the trap:
955
+
956
+ * ``${...}`` is a VS Code **variable substitution** (``${workspaceFolder}``,
957
+ ``${env:HOME}``). VS Code expands it itself at launch, so it is fully
958
+ resolved as far as we are concerned. No angle bracket is involved.
959
+ * ``<...>`` is ours -- ``<resolved-device>``, ``<resolved-svd>``,
960
+ ``<resolved-openocd-board-cfg>``, ``<resolved-gdb>``,
961
+ ``<resolved-target-id>``, and the two-token ``<host>:<port>``. Nothing
962
+ expands these; handed to a debug adapter verbatim they are a literal
963
+ device name / path / TCP address.
964
+
965
+ So the test is any angle-bracket token, NOT a ``<resolved-`` prefix. The
966
+ prefix test passed ``<host>:<port>`` as a real address -- the hole
967
+ alp-sdk-vscode found, where a yocto profile reported launchable with an
968
+ unusable gdbserver address.
969
+ """
970
+ # Equivalent to the TS `/<[^<>]*>/`: after splitting on `<`, no fragment can
971
+ # contain another `<`, so a fragment holding a `>` IS a `<...>` token.
972
+ return any(">" in rest for rest in value.split("<")[1:])
973
+
974
+
975
+ def _is_unresolved(value: Any) -> bool:
976
+ return isinstance(value, str) and is_unresolved_placeholder(value)
977
+
978
+
979
+ def _is_resolved(value: Any) -> bool:
980
+ return isinstance(value, str) and not is_unresolved_placeholder(value)
981
+
982
+
983
+ def _list_item_identity(item: Any) -> Any:
984
+ """The stable identity a per-list-element merge matches an incoming
985
+ (draft) item against an existing one BY, before falling back to shared
986
+ index (see [`_merge_list_by_identity`]).
987
+
988
+ `setupCommands`' dicts are identified by their own `text` field -- the
989
+ real cppdbg command string, the one thing that names WHICH command this
990
+ is; `ignoreFailures` is a modifier on that SAME command, not a separate
991
+ identity. Anything else (a bare string -- `configFiles`'s own shape -- or
992
+ a dict with no `text`) is its own identity, compared by value.
993
+ """
994
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
995
+ return item["text"]
996
+ return item
997
+
998
+
999
+ def _merge_list_by_identity(
1000
+ existing: list[Any],
1001
+ next_value: list[Any],
1002
+ tan_owned_hashes: frozenset[str] = frozenset(),
1003
+ ) -> tuple[list[Any], list[Any]]:
1004
+ """Merge `next_value` into `existing`. Port target: `_merge_value`'s list
1005
+ branch, factored out so its own docstring can stay about the OVERALL
1006
+ merge rule.
1007
+
1008
+ Returns `(result, owned_entries)`: the merged list, and the subset of
1009
+ `result` this run identifies as tan-authored after this merge -- every
1010
+ entry pass 1 matched (merged in place), every entry pass 2 actually
1011
+ placed, and every entry pass 4 appended. NEVER an entry pass 3 left
1012
+ untouched (that is either the customer's own, or a previously-tan entry
1013
+ this run had no reason to touch -- either way, this run makes no fresh
1014
+ claim about it). The caller (`_merge_list_field`, in turn
1015
+ `_merge_configuration`) hashes `owned_entries` into the `.alp/`
1016
+ provenance sidecar (tan-cli#518) so a LATER run can tell these entries
1017
+ apart from the customer's without relying on position.
1018
+
1019
+ tan-cli#489 review round (second pass): identity-only matching, with no
1020
+ positional fallback, was NON-IDEMPOTENT -- measured through the real CLI,
1021
+ rebuilding only `runners.yaml`'s `--config=` between runs, three
1022
+ consecutive `tan debug-config` runs left `configFiles` holding all THREE
1023
+ values, because an incoming value that matches NOTHING already in the
1024
+ file (the whole point: it is a NEW resolution) was always treated as an
1025
+ ADDITION, never a REPLACEMENT of whatever tan itself wrote last time.
1026
+ OpenOCD sources every `-f` in turn, so that is not "a stale value
1027
+ surviving one run too many" -- it is repeated `target create` on the
1028
+ same TAP, and the session fails to start at all. The SAME version also
1029
+ emitted in the DRAFT's own order rather than `existing`'s, which could
1030
+ sequence an adapter driver AFTER the config that needs it, or reorder
1031
+ `setupCommands`, whose commands gdb executes in order.
1032
+
1033
+ tan-cli#489 review round (THIRD pass): the second pass's positional
1034
+ fallback used the UNMATCHED draft item's OWN index `i` against
1035
+ `existing[i]` directly -- correct only when nothing before `i` in the
1036
+ draft has ALSO matched something in `existing`, which shifts the two
1037
+ index spaces out of alignment. Measured: a customer-prepended
1038
+ `configFiles` entry ahead of tan's own two resolved values (`interface`,
1039
+ which matches every run, plus a per-revision `target/revN.cfg`) meant
1040
+ `i`, the target entry's DRAFT index, never lined up with a genuinely
1041
+ free `existing` slot -- `i` was always either already consumed by the
1042
+ interface match or past `len(existing)` -- so it fell to the `append`
1043
+ branch on EVERY run, accumulating one more revision each time. Still
1044
+ fatal to the same OpenOCD session for the same reason; still reachable
1045
+ through ordinary multi-`--config`/`openocd_search` boards, not an
1046
+ exotic shape.
1047
+
1048
+ **Anchor-relative placement replaces index-relative placement.** An
1049
+ "anchor" is a draft item that DID identity-match, at the `existing`
1050
+ index it matched -- these are fixed points neither pass moves. An
1051
+ unmatched draft item is placed into the first free `existing` slot that
1052
+ lies AFTER the nearest anchor before it (in draft order) and BEFORE the
1053
+ nearest anchor after it -- i.e. between the two anchors bracketing its
1054
+ position in the draft, not at its own raw index. This is deliberately
1055
+ NOT the simpler "collect every unmatched draft item, then assign them to
1056
+ every free existing slot in existing's own order" two-pass: that
1057
+ ignores which UNMATCHED items are near which anchors, so a customer's
1058
+ LEADING entry (no anchor before it at all) is just as free a slot as any
1059
+ other and can still be claimed by an unrelated tan-resolved value.
1060
+
1061
+ 1. Every draft item is matched against an UNCONSUMED `existing` entry
1062
+ with the SAME [`_list_item_identity`], wherever it sits -- merged
1063
+ recursively there (so a matched `setupCommands` dict keeps a
1064
+ customer-added key like `ignoreFailures` the draft never writes), IN
1065
+ PLACE, never moved. This is what fixes the reordering defect: the
1066
+ result is always emitted in `existing`'s OWN order, not the draft's.
1067
+ 2. A draft item with NO identity match ANYWHERE is tan's own NEW
1068
+ resolution for a field it has always owned positionally -- placed at
1069
+ the first `existing` index strictly between the anchor of the
1070
+ nearest matched draft item BEFORE it (or the start of `existing`, if
1071
+ none) and the anchor of the nearest matched draft item AFTER it (or
1072
+ the end of `existing`, if none) that no earlier placement in THIS
1073
+ merge already claimed. This is what fixes the accumulation defect
1074
+ for the general case, not only the no-anchors-at-all one: a value
1075
+ that keeps replacing a PRIOR run's own single resolved value
1076
+ overwrites it in place regardless of what else in the list matched
1077
+ by identity around it.
1078
+ 3. Anything neither an anchor nor a placement claims is untouched, kept
1079
+ at its own position: the customer's own addition tan never resolves
1080
+ for (a hand-added second `.cfg`, an extra `setupCommands` entry), the
1081
+ tan-cli#489 (3) case this whole function exists for.
1082
+ 4. A draft item with no identity match and no free slot in its own
1083
+ bracketing window (the window is empty, or every slot in it was
1084
+ already claimed by an earlier placement in this same merge) is a
1085
+ genuinely NEW value -- appended.
1086
+
1087
+ **Position alone is a heuristic, not real provenance** -- tan-cli#489's
1088
+ own "Known, accepted limitation": a customer's hand-added entry that (a)
1089
+ matches nothing in the fresh draft AND (b) sits in the SAME bracketing
1090
+ window an unmatched draft item is being placed into could be silently
1091
+ overwritten, e.g. `["mine.cfg"] + ["board/x.cfg"] -> ["board/x.cfg"]`,
1092
+ no anchors on either side to protect `mine.cfg`. tan-cli#518 closes that
1093
+ gap: pass 2's placement additionally requires `launch_provenance.
1094
+ content_hash(existing[slot])` to already be a member of
1095
+ `tan_owned_hashes` -- i.e. that exact entry is recorded, in the `.alp/`
1096
+ sidecar, as something TAN itself wrote on the run that last touched this
1097
+ field. A slot whose content hash is unrecorded (never written by tan, or
1098
+ edited/reformatted since) is skipped exactly as if it were `claimed`,
1099
+ which starves the placement search and falls through to case 4
1100
+ (appended) instead of case 2 (overwritten).
1101
+
1102
+ `tan_owned_hashes` defaults to empty -- a caller with no sidecar (never
1103
+ read one, or read one that was missing/unreadable/schema-mismatched;
1104
+ see `launch_provenance.load`) gets the maximally conservative behaviour:
1105
+ pass 2 NEVER overwrites, every unmatched draft item is appended. That is
1106
+ a deliberate one-run degradation, not a bug -- see the module docstring
1107
+ of `launch_provenance` for the asymmetry this is built to preserve. The
1108
+ very fact that this run appends (rather than silently discarding) means
1109
+ its own [`_merge_list_field`] caller can then record the appended
1110
+ entry's hash, so the NEXT run recognises it and pass 2 works normally
1111
+ again -- the sidecar self-heals from empty within one write.
1112
+ """
1113
+ n_existing = len(existing)
1114
+ # Pass 1: identity match, anywhere in `existing`, greedily consuming at
1115
+ # most one `existing` index per match. Keyed by DRAFT index so pass 2 can
1116
+ # look up "the nearest matched draft item before/after me" directly.
1117
+ anchor_of_draft_index: dict[int, int] = {}
1118
+ consumed: set[int] = set()
1119
+ for i, item in enumerate(next_value):
1120
+ identity = _list_item_identity(item)
1121
+ match_index = next(
1122
+ (
1123
+ j
1124
+ for j, existing_item in enumerate(existing)
1125
+ if j not in consumed and _list_item_identity(existing_item) == identity
1126
+ ),
1127
+ None,
1128
+ )
1129
+ if match_index is not None:
1130
+ anchor_of_draft_index[i] = match_index
1131
+ consumed.add(match_index)
1132
+
1133
+ result = list(existing)
1134
+ for i, match_index in anchor_of_draft_index.items():
1135
+ result[match_index] = _merge_value(existing[match_index], next_value[i])
1136
+
1137
+ # Pass 2: anchor-relative placement for every draft item pass 1 did not
1138
+ # match, processed in DRAFT order so "first free slot" within a window
1139
+ # shared by more than one unmatched item is deterministic.
1140
+ sorted_anchor_draft_indices = sorted(anchor_of_draft_index)
1141
+ claimed = set(anchor_of_draft_index.values())
1142
+ appended: list[Any] = []
1143
+ placed_slots: list[int] = []
1144
+ for i, item in enumerate(next_value):
1145
+ if i in anchor_of_draft_index:
1146
+ continue
1147
+ window_start = -1
1148
+ for j in sorted_anchor_draft_indices:
1149
+ if j >= i:
1150
+ break
1151
+ window_start = anchor_of_draft_index[j]
1152
+ window_end = n_existing
1153
+ for j in sorted_anchor_draft_indices:
1154
+ if j > i:
1155
+ window_end = anchor_of_draft_index[j]
1156
+ break
1157
+ # tan-cli#518: a slot is only a genuine placement TARGET when its
1158
+ # CURRENT content hashes to something `tan_owned_hashes` already
1159
+ # knows tan wrote -- otherwise it is treated exactly like an already
1160
+ # `claimed` slot (skipped, never overwritten).
1161
+ slot = next(
1162
+ (
1163
+ k
1164
+ for k in range(window_start + 1, window_end)
1165
+ if k not in claimed
1166
+ and launch_provenance.content_hash(existing[k]) in tan_owned_hashes
1167
+ ),
1168
+ None,
1169
+ )
1170
+ if slot is not None:
1171
+ result[slot] = _merge_value(existing[slot], item)
1172
+ claimed.add(slot)
1173
+ placed_slots.append(slot)
1174
+ else:
1175
+ appended.append(item)
1176
+ result.extend(appended)
1177
+
1178
+ owned_entries = (
1179
+ [result[match_index] for match_index in anchor_of_draft_index.values()]
1180
+ + [result[slot] for slot in placed_slots]
1181
+ + appended
1182
+ )
1183
+ return result, owned_entries
1184
+
1185
+
1186
+ def _merge_list_field(
1187
+ existing: list[Any],
1188
+ next_value: list[Any],
1189
+ tan_owned_hashes: frozenset[str] = frozenset(),
1190
+ ) -> tuple[list[Any], list[Any]]:
1191
+ """The full merge for one `configFiles`/`setupCommands`-shaped launch-
1192
+ configuration field: the all-placeholder guard, then
1193
+ [`_merge_list_by_identity`]. Factored out of [`_merge_configuration`]'s
1194
+ per-key loop (the only real caller -- `_merge_value` no longer merges
1195
+ list pairs at all, see its own docstring) so that loop can pass
1196
+ field-specific `tan_owned_hashes` and collect the returned
1197
+ `owned_entries` without duplicating the guard.
1198
+
1199
+ Returns `(merged, owned_entries)`; `owned_entries` is empty when the
1200
+ all-placeholder guard fires, because this run resolved NOTHING for the
1201
+ field -- see [`_merge_configuration`]'s docstring for why that means the
1202
+ field's provenance record is left exactly as it already was, not wiped.
1203
+ """
1204
+ # cortex-debug `configFiles`: an all-placeholder incoming list keeps the
1205
+ # existing list WHOLE, or a hand-added second `.cfg` is lost to a
1206
+ # per-index merge against a one-element draft. A mixed list still merges
1207
+ # per element, so an entry we did resolve wins.
1208
+ if next_value and existing and all(_is_unresolved(v) for v in next_value):
1209
+ return list(existing), []
1210
+ return _merge_list_by_identity(existing, next_value, tan_owned_hashes)
1211
+
1212
+
1213
+ def _load_files_is_tan_owned(existing: list[Any], tan_owned_hashes: frozenset[str]) -> bool:
1214
+ """Whether an existing `loadFiles` value is provably tan's own prior
1215
+ output rather than something a customer typed: every entry's content
1216
+ hash is recorded in the `.alp/` sidecar as this field's own last write.
1217
+
1218
+ An EMPTY existing list can never satisfy this. `create_launch_draft`
1219
+ never emits an empty `loadFiles` -- "this draft has no attach-only shape
1220
+ for a caller to opt into yet" (tan-cli#945's own docstring, still true
1221
+ here) -- so a customer's `[]` predates tan ever writing this key at all
1222
+ and is unconditionally hand-authored. Treating it as "vacuously owned"
1223
+ (the naive reading of `all(... for v in [])`) is exactly the tan-cli#1020
1224
+ review blocker: an intentional attach-only `[]` silently turned into a
1225
+ session that programs silicon, at exit 0 with `issues: []`.
1226
+ """
1227
+ if not existing:
1228
+ return False
1229
+ return all(launch_provenance.content_hash(v) in tan_owned_hashes for v in existing)
1230
+
1231
+
1232
+ def _merge_load_files(
1233
+ existing: list[Any], incoming: list[Any], tan_owned_hashes: frozenset[str]
1234
+ ) -> tuple[list[Any], list[Any]]:
1235
+ """`loadFiles`'s own merge rule (tan-cli#1020 review) -- deliberately NOT
1236
+ [`_merge_list_field`]'s identity-plus-positional-append rule its
1237
+ siblings `configFiles`/`setupCommands` use.
1238
+
1239
+ Those two fields hold INDEPENDENT entries a customer and tan can each
1240
+ legitimately contribute one of (a customer's extra `.cfg`, tan's own
1241
+ resolved one), so appending tan's fresh entry beside an existing one
1242
+ provenance cannot confirm is tan's own is the SAFE outcome
1243
+ (tan-cli#518/#982). `loadFiles` is not that shape: it names ONE
1244
+ deliberate artefact list per configuration -- normally the same file
1245
+ `executable` does, or an explicit `[]` a customer wrote to make the
1246
+ session attach-only -- so applying that same "append when unproven"
1247
+ rule here means cortex-debug programs BOTH files when a customer typed
1248
+ their own single entry, and an attach-only `[]` gets a fresh entry
1249
+ appended into it, which is indistinguishable from overwriting the
1250
+ customer's explicit "program nothing" (tan-cli#1020 review, measured:
1251
+ `[]` -> `[<fresh elf>]`, `["custom/app.hex"]` ->
1252
+ `["custom/app.hex", "<fresh elf>"]`, both at exit 0 with `issues: []`).
1253
+
1254
+ So the whole list is the unit, not its elements: a `loadFiles` this run
1255
+ cannot prove it wrote itself (see [`_load_files_is_tan_owned`]) is left
1256
+ EXACTLY as the file already had it -- no merge, no append -- and the
1257
+ returned `owned` list is empty, so a later run still treats it as the
1258
+ customer's, not tan's. A `loadFiles` this run CAN prove is its own prior
1259
+ output (or that already equals the fresh resolution, nothing to change
1260
+ either way) is replaced wholesale, the same "an updated build makes a
1261
+ stale value updateable again" rule every OTHER field in this module
1262
+ already gets from [`_merge_value`] -- and matches what
1263
+ `apply_launch_resolution` already keeps in sync with `executable`.
1264
+
1265
+ This function alone cannot un-protect a value once it lands here: the
1266
+ KEY-ABSENT case that lets `loadFiles` re-establish provenance after a
1267
+ lost sidecar is handled one level up, in
1268
+ [`_merge_configuration`]'s own key-absent branch, before a pair ever
1269
+ reaches this function. See `launch_provenance`'s module docstring
1270
+ ("``loadFiles`` heals the same way for the same reason, but only from
1271
+ ONE specific starting point") for why this function's own protect
1272
+ branch, unlike that branch, can never safely self-heal from a value
1273
+ already sitting in the file.
1274
+ """
1275
+ if existing == incoming:
1276
+ return list(incoming), list(incoming)
1277
+ if _load_files_is_tan_owned(existing, tan_owned_hashes):
1278
+ return list(incoming), list(incoming)
1279
+ return list(existing), []
1280
+
1281
+
1282
+ def _merge_value(existing: Any, next_value: Any) -> Any:
1283
+ """Merge one incoming SCALAR or nested-DICT value over what the file
1284
+ already holds. List pairs never reach here -- [`_merge_configuration`]'s
1285
+ per-key loop intercepts every `(list, list)` pair itself, so it can pass
1286
+ field-specific provenance to [`_merge_list_field`] and record what that
1287
+ merge decided was tan-owned (tan-cli#518); nothing else in this module
1288
+ calls this function with two lists (a `configFiles` entry is a bare
1289
+ string, a `setupCommands` entry is a dict with no list-valued key of its
1290
+ own), so there is no second code path to keep in sync.
1291
+
1292
+ The whole rule: **an incoming unresolved ``<...>`` placeholder never
1293
+ overwrites a concrete existing value.** That is also what tells "the
1294
+ customer set this deliberately" apart from "this is our old output" -- our
1295
+ output for a field we could not resolve is *literally* an angle-bracket
1296
+ token, so anything concrete in the file is either the customer's or a real
1297
+ value we computed. The inverse still works: whenever this run CAN resolve a
1298
+ field, the incoming value is concrete and overwrites unconditionally, so a
1299
+ stale value that is now wrong is still updateable.
1300
+
1301
+ Rust distinguishes "key absent" (``None``) from "key present holding JSON
1302
+ null" (``Some(Value::Null)``); here both arrive as ``None``, and both take
1303
+ the same branch in the Rust too -- ``is_resolved(Some(Null))`` is false and
1304
+ ``Value::Null`` is not an array -- so the distinction has nothing to decide.
1305
+ """
1306
+ if isinstance(next_value, dict) and isinstance(existing, dict):
1307
+ # tan-cli#489 (3): recurse instead of replacing wholesale. A dict
1308
+ # *inside* a list element (`setupCommands`' `{"text": ..., "ignoreFailures":
1309
+ # ...}`) can carry a customer-added key our own draft never writes;
1310
+ # only [`_merge_configuration`]'s "visit only the incoming keys"
1311
+ # discipline preserves it, the same protection top-level launch-config
1312
+ # keys already had.
1313
+ return _merge_configuration(existing, next_value)
1314
+ if _is_unresolved(next_value) and _is_resolved(existing):
1315
+ return existing
1316
+ return next_value
1317
+
1318
+
1319
+ def _merge_configuration(
1320
+ existing: Any,
1321
+ next_value: Any,
1322
+ *,
1323
+ tan_owned_hashes_for: Callable[[str], frozenset[str]] | None = None,
1324
+ owned_entries_out: dict[str, list[Any]] | None = None,
1325
+ ) -> Any:
1326
+ """Merge the freshly generated configuration OVER the one already in the
1327
+ file (see [`_merge_value`]) instead of replacing it.
1328
+
1329
+ This runs before every session and the configuration names are fixed per
1330
+ target/server, so a wholesale replace meant a customer told to hand-fill
1331
+ ``"device": "AE822F4M55_HP"`` got it reset to ``"<resolved-device>"`` on
1332
+ their next F5 -- data loss on their own file, no confirm, no backup, and an
1333
+ unexitable loop around the advice we had just given them (#105).
1334
+
1335
+ Key order follows the existing entry with new keys appended, and keys the
1336
+ customer added that we never write (``serverArgs``, ...) are left untouched
1337
+ because only the draft's own keys are visited.
1338
+
1339
+ ``tan_owned_hashes_for``/``owned_entries_out`` (tan-cli#518) are this
1340
+ function's only awareness of list-field provenance, and both are
1341
+ optional -- every OTHER caller (the recursive `setupCommands`-entry merge
1342
+ inside [`_merge_value`]) omits them and gets the pre-#518 behaviour
1343
+ exactly (`tan_owned_hashes_for=None` reads as "no sidecar", i.e. the same
1344
+ empty-hash-set default [`_merge_list_field`] already has). Only the
1345
+ TOP-level call from [`create_launch_json_write_plan`] passes both: a
1346
+ `(list, list)` key pair is intercepted HERE, before it would otherwise
1347
+ reach [`_merge_value`], specifically so this loop can look up THIS key's
1348
+ own recorded hashes and capture which entries the merge decided were
1349
+ tan-owned afterwards, into ``owned_entries_out[key]`` -- the caller then
1350
+ hashes those into the `.alp/` sidecar. A key this run never visits (not
1351
+ in ``next_value`` at all) leaves ``owned_entries_out`` untouched for it,
1352
+ which is what makes [`launch_provenance.LaunchProvenance.updated`]'s
1353
+ "only replace the fields actually touched" contract true from this end.
1354
+ """
1355
+ if not isinstance(existing, dict) or not isinstance(next_value, dict):
1356
+ return next_value
1357
+ merged = dict(existing)
1358
+ for key, value in next_value.items():
1359
+ existing_val = existing.get(key)
1360
+ if isinstance(value, list) and isinstance(existing_val, list):
1361
+ hashes = tan_owned_hashes_for(key) if tan_owned_hashes_for is not None else frozenset()
1362
+ # tan-cli#1020 review: `loadFiles` gets its OWN whole-list merge
1363
+ # rule, not `configFiles`/`setupCommands`' identity-plus-append
1364
+ # one -- see `_merge_load_files`'s own docstring for why the two
1365
+ # shapes are not interchangeable.
1366
+ if key == "loadFiles":
1367
+ merged_list, owned = _merge_load_files(existing_val, value, hashes)
1368
+ else:
1369
+ merged_list, owned = _merge_list_field(existing_val, value, hashes)
1370
+ merged[key] = merged_list
1371
+ if owned_entries_out is not None and owned:
1372
+ owned_entries_out[key] = owned
1373
+ elif isinstance(value, list) and key not in existing:
1374
+ # tan-cli#1020 re-review: the entry already exists (this is the
1375
+ # MERGE path, not the brand-new-entry branch below), but this
1376
+ # particular list-valued key is genuinely ABSENT from it -- e.g. a
1377
+ # pre-#945 `tan` wrote this configuration before `loadFiles`
1378
+ # existed at all. `key not in existing`, deliberately NOT
1379
+ # `existing_val is None`: `existing.get(key)` returns `None` for
1380
+ # BOTH "the key is absent" and "the key is present holding JSON
1381
+ # `null`", and those are not the same fact -- an explicit
1382
+ # `"loadFiles": null` some tool or hand-edit wrote is a concrete
1383
+ # value sitting in the file, indistinguishable from a customer's
1384
+ # `[]` in every way that matters here (measured pre-fix: an
1385
+ # `is None` check silently overwrote a `null` value AND recorded
1386
+ # it as tan-owned, exactly the "nothing to protect" mistake this
1387
+ # whole branch exists to avoid for the KEY-ABSENT case only).
1388
+ # That is also NOT the same fact as the key being PRESENT and
1389
+ # holding `[]` (a customer's own explicit attach-only marker, or
1390
+ # a value some other run already decided not to touch -- see
1391
+ # `_load_files_is_tan_owned`'s docstring for why those two must
1392
+ # never be conflated), so this is deliberately its own branch
1393
+ # rather than substituting `[]` for `existing_val` and falling
1394
+ # into the branch above.
1395
+ #
1396
+ # With nothing here for a customer to have hand-authored, this
1397
+ # run's fresh value is unambiguously its own -- exactly the same
1398
+ # reasoning `create_launch_json_write_plan`'s brand-new-entry case
1399
+ # already applies to a whole configuration, just at the single-key
1400
+ # level. Recording it now is what lets `_merge_load_files`'s
1401
+ # protect branch heal on the VERY NEXT write instead of pinning a
1402
+ # value it can never afterwards prove is tan's own -- the
1403
+ # permanent-staleness bug the 1020 re-review found: without this,
1404
+ # the first write after upgrading past #945 (or after the `.alp/`
1405
+ # sidecar is lost) leaves `loadFiles` frozen forever, even as
1406
+ # `executable` keeps tracking every fresh build resolution.
1407
+ merged[key] = value
1408
+ if owned_entries_out is not None and value:
1409
+ owned_entries_out[key] = list(value)
1410
+ else:
1411
+ merged[key] = _merge_value(existing_val, value)
1412
+ return merged
1413
+
1414
+
1415
+ @dataclass
1416
+ class LaunchJsonWritePlan:
1417
+ """Result of merging a draft into launch.json."""
1418
+
1419
+ #: Pretty-printed launch.json content, trailing newline included.
1420
+ content: str
1421
+ #: `True` if an existing same-named configuration was replaced; `False` if appended.
1422
+ replaced: bool
1423
+ #: The legacy `"ALP: ..."` name of an entry ADOPTED onto the current
1424
+ #: `"Alp: ..."` name this run. `None` on every other path, including when a
1425
+ #: current-named entry already existed (that case never looks for a legacy
1426
+ #: counterpart at all).
1427
+ migrated_from: str | None
1428
+ #: `True` when this write discarded a comment (or trailing comma) that sat
1429
+ #: inside the span actually rewritten -- the one maintained entry the splice
1430
+ #: replaced, or, on the whole-document fallback, anywhere in the original
1431
+ #: file. `False` on a fresh file, an append (nothing existing is ever
1432
+ #: touched), and the no-op path where `content` is `original` verbatim.
1433
+ comments_dropped: bool
1434
+ #: The legacy `"ALP: ..."` name of an entry that STILL sits in the file,
1435
+ #: untouched, because the ordinary same-name merge ran instead (an
1436
+ #: exact-name HIT against the current name). tan-cli#179: this used to be
1437
+ #: silent -- the customer's real hand-filled values can still be stranded on
1438
+ #: that leftover entry (the #133 symptom), with nothing pointing at it.
1439
+ legacy_entry_present: str | None
1440
+ #: The one configuration entry actually written this run -- the
1441
+ #: merged/migrated result for a replace, or the draft itself for an append.
1442
+ #: tan-cli#180: distinct from the DRAFT the caller passed in, which still
1443
+ #: carries its own fresh `<resolved-...>` placeholders even when this run
1444
+ #: merged over a customer's real, resolved values.
1445
+ written_configuration: Any
1446
+ #: The `.alp/` sidecar record (tan-cli#518) AFTER this run -- the caller's
1447
+ #: own `provenance` argument with `written_configuration`'s list fields
1448
+ #: (re)recorded, never mutated in place (`LaunchProvenance.updated` always
1449
+ #: returns a fresh copy). Persist this back to
1450
+ #: `launch_provenance.sidecar_path(workspace_root)` alongside the
1451
+ #: `launch.json` write -- a caller that discards it (never writes the
1452
+ #: sidecar back out) simply keeps degrading to the "nothing is ours"
1453
+ #: default forever, which is safe, just permanently conservative.
1454
+ provenance: LaunchProvenance
1455
+
1456
+
1457
+ def _legacy_name(next_name: str) -> str | None:
1458
+ """The pre-#155 spelling of a current launch-configuration name, or ``None``
1459
+ if ``next_name`` does not use the current ``"Alp: "`` prefix (defensive:
1460
+ every name [`create_launch_draft`] emits does).
1461
+
1462
+ Deliberately narrow: this computes the ONE legacy string corresponding to
1463
+ ``next_name`` -- one of the four names this module ever emits -- rather than
1464
+ matching any configuration whose name happens to start with ``"ALP: "``. A
1465
+ customer's own unrelated ``"ALP: My Custom Config"`` is never touched.
1466
+ """
1467
+ if next_name.startswith("Alp: "):
1468
+ return f"ALP: {next_name[len('Alp: '):]}"
1469
+ return None
1470
+
1471
+
1472
+ def create_launch_json_write_plan(
1473
+ existing_content: str | None,
1474
+ draft: dict[str, Any],
1475
+ explicit_omissions: frozenset[str] = frozenset(),
1476
+ provenance: LaunchProvenance | None = None,
1477
+ ) -> LaunchJsonWritePlan:
1478
+ """Merge ``draft`` into an existing launch.json (or a fresh document),
1479
+ merging key-by-key over any configuration with the same ``name``. Mirrors TS
1480
+ ``createLaunchJsonWritePlan``.
1481
+
1482
+ ``provenance`` (tan-cli#518) is the `.alp/` sidecar record from the last
1483
+ ``tan debug-config`` write, or ``None`` -- treated identically to
1484
+ ``launch_provenance.empty()`` -- when the caller never read one (a fresh
1485
+ project, a deleted/unreadable/corrupt sidecar). It gates ONLY
1486
+ [`_merge_list_by_identity`]'s positional fallback for `configFiles`/
1487
+ `setupCommands`; every other field's merge rule is unchanged by this
1488
+ parameter. The returned plan's own `provenance` field is what THIS run
1489
+ decided is tan-owned after the merge -- the caller persists it back to
1490
+ `launch_provenance.sidecar_path(workspace_root)` for the NEXT run.
1491
+
1492
+ #133 (reopened): the #155 rename to ``"Alp: ..."`` left any entry still
1493
+ spelled ``"ALP: ..."`` orphaned -- nothing matched it by exact name any more,
1494
+ so it silently stopped receiving merges. That is not cosmetic: the orphaned
1495
+ entry is exactly where a customer's own hand-resolved fields already lived,
1496
+ and the maintained entry kept its placeholder. So a MISS on the current name
1497
+ falls through to a search for that one legacy counterpart before giving up
1498
+ and appending fresh, and a hit there is folded in via the SAME
1499
+ [`_merge_configuration`] a same-named update already uses.
1500
+
1501
+ This only ever fires on a MISS. When a current-named entry already exists --
1502
+ whether or not a legacy one *also* still sits in the file -- this takes the
1503
+ ordinary same-name-replace path and never looks for a legacy counterpart.
1504
+ The legacy entry is left exactly as it is: nothing decides which of two
1505
+ possibly-hand-edited entries is authoritative, so nothing is merged or
1506
+ deleted on this run's say-so (tan-cli#179 reports it instead).
1507
+
1508
+ ``explicit_omissions`` (tan-cli#489 (6)): the KEYS the caller explicitly
1509
+ asked to have no value for this run -- today only ``preLaunchTask``, via
1510
+ ``--pre-launch-task ''``. [`create_launch_draft`] implements that opt-out
1511
+ by building the key then deleting it, which is indistinguishable, to
1512
+ [`_merge_configuration`]'s "only visit the draft's OWN keys" rule, from
1513
+ ``preLaunchTask`` never existing on this target's shape at all (the
1514
+ yocto-userspace "no default" case, tan-cli#138 vs #321, which must stay
1515
+ untouched when the file already carries one). Both mean "the key is
1516
+ absent from ``draft``"; only one of them means "remove it from the
1517
+ file too". So an explicit opt-out merged against an entry that already
1518
+ carries a ``preLaunchTask`` from a PRIOR run silently kept the old value
1519
+ -- exit 0, ``issues: []``, no signal the flag did nothing. `--pre-launch-
1520
+ task ''`'s whole point (alp-sdk-vscode#406) is escaping a task that fails
1521
+ every F5; the escape hatch not working on the one file that matters is
1522
+ the same shape of bug this run's OTHER four defects are. Applied AFTER
1523
+ the ordinary merge, on the merged result -- never on `pre_merge` or
1524
+ `draft` themselves, so `_merge_configuration`'s own "visit only the
1525
+ incoming keys" contract stays true for every OTHER key.
1526
+ """
1527
+ if provenance is None:
1528
+ provenance = launch_provenance.empty()
1529
+ document = _parse_launch_json_or_default(existing_content)
1530
+ next_name = _configuration_name(draft)
1531
+ configs = document["configurations"]
1532
+
1533
+ existing_index = next(
1534
+ (i for i, c in enumerate(configs) if c.get("name") == next_name), None
1535
+ )
1536
+
1537
+ replaced = False
1538
+ migrated_from: str | None = None
1539
+ legacy_entry_present: str | None = None
1540
+ # `splice_index` mirrors, into the ORIGINAL raw text, exactly which element
1541
+ # `entry` is replacing (`None` means append). It indexes the SAME filtered,
1542
+ # object-only ordering `_parse_launch_json_or_default` applied, so it lines
1543
+ # up with `jsonc_splice`'s own object-only element count without either side
1544
+ # re-deriving the other's filter.
1545
+ splice_index: int | None = None
1546
+ unchanged = False
1547
+ # tan-cli#518: every list field this run TOUCHES records what it decided
1548
+ # was tan-owned, keyed by field name -- looked up here (once, under
1549
+ # `next_name`: the sidecar is never keyed by a legacy name, since the
1550
+ # migration path below predates provenance entirely) and handed to
1551
+ # `_merge_configuration` so it can populate `owned_entries` as it merges.
1552
+ owned_entries: dict[str, list[Any]] = {}
1553
+
1554
+ def _tan_owned_hashes_for(field_name: str) -> frozenset[str]:
1555
+ return provenance.hashes_for(next_name, field_name)
1556
+
1557
+ if existing_index is not None:
1558
+ pre_merge = configs[existing_index]
1559
+ entry = _merge_configuration(
1560
+ pre_merge,
1561
+ draft,
1562
+ tan_owned_hashes_for=_tan_owned_hashes_for,
1563
+ owned_entries_out=owned_entries,
1564
+ )
1565
+ for key in explicit_omissions:
1566
+ entry.pop(key, None)
1567
+ unchanged = entry == pre_merge
1568
+ configs[existing_index] = entry
1569
+ legacy = _legacy_name(next_name)
1570
+ if legacy is not None and any(c.get("name") == legacy for c in configs):
1571
+ legacy_entry_present = legacy
1572
+ replaced = True
1573
+ splice_index = existing_index
1574
+ else:
1575
+ legacy = _legacy_name(next_name)
1576
+ legacy_index = (
1577
+ next((i for i, c in enumerate(configs) if c.get("name") == legacy), None)
1578
+ if legacy is not None
1579
+ else None
1580
+ )
1581
+ if legacy_index is not None:
1582
+ pre_merge = configs[legacy_index]
1583
+ migrated_from = pre_merge.get("name")
1584
+ entry = _merge_configuration(
1585
+ pre_merge,
1586
+ draft,
1587
+ tan_owned_hashes_for=_tan_owned_hashes_for,
1588
+ owned_entries_out=owned_entries,
1589
+ )
1590
+ for key in explicit_omissions:
1591
+ entry.pop(key, None)
1592
+ unchanged = entry == pre_merge
1593
+ configs[legacy_index] = entry
1594
+ replaced = True
1595
+ splice_index = legacy_index
1596
+ else:
1597
+ # A brand-new entry: every list field the draft itself carries is
1598
+ # tan's own fresh output, with nothing to merge against -- record
1599
+ # all of them (tan-cli#518), same as case 4 (appended) would for
1600
+ # an ordinary merge.
1601
+ entry = dict(draft)
1602
+ configs.append(entry)
1603
+ for key, value in entry.items():
1604
+ if isinstance(value, list) and value:
1605
+ owned_entries[key] = list(value)
1606
+
1607
+ # tan-cli#182 review finding #1: a semantically no-op re-run (the merged
1608
+ # entry is identical, ignoring formatting, to what was already there) still
1609
+ # spliced the maintained entry back into itself, which reformats it and
1610
+ # discards any comment sitting inside -- on a file the extension re-runs
1611
+ # `debug-config` against on every session, "nothing changed" is the COMMON
1612
+ # case, not an edge case. The splice only ever touches the one entry that
1613
+ # changed, so an unchanged entry means an unchanged document: skip the write
1614
+ # entirely and hand back `original`'s own bytes.
1615
+ if unchanged:
1616
+ # An unchanged merge only happens against an existing entry, so
1617
+ # `existing_content` cannot be None here -- but assert it rather than
1618
+ # letting a future refactor turn it into a silent `"None"` write.
1619
+ assert existing_content is not None
1620
+ content, comments_dropped = existing_content, False
1621
+ else:
1622
+ content, comments_dropped = _write_content(
1623
+ existing_content, document, splice_index, entry
1624
+ )
1625
+ return LaunchJsonWritePlan(
1626
+ content=content,
1627
+ replaced=replaced,
1628
+ migrated_from=migrated_from,
1629
+ comments_dropped=comments_dropped,
1630
+ legacy_entry_present=legacy_entry_present,
1631
+ written_configuration=entry,
1632
+ provenance=provenance.updated(next_name, owned_entries),
1633
+ )
1634
+
1635
+
1636
+ def _write_content(
1637
+ existing_content: str | None,
1638
+ document: dict[str, Any],
1639
+ splice_index: int | None,
1640
+ entry: Any,
1641
+ ) -> tuple[str, bool]:
1642
+ """Render the write plan's final bytes.
1643
+
1644
+ tan-cli#182: this used to be a whole-document re-serialise unconditionally,
1645
+ which destroyed every comment, trailing comma and leading BOM in a
1646
+ customer's hand-edited launch.json on every single run, not just the one
1647
+ entry being changed. Now the write is a targeted splice into
1648
+ ``existing_content``'s own text whenever
1649
+ ``jsonc_splice.locate_configuration_edit`` can confidently place it:
1650
+ everything outside the edited entry is copied through unconditionally,
1651
+ because there is no re-serialisation pass over it to lose anything. Only the
1652
+ entry actually being written is reformatted -- that entry's own prior
1653
+ comments are the one unavoidable casualty, the same way any tool that edits
1654
+ one JSON object's fields must discard stray comments sitting BETWEEN those
1655
+ fields; a comment ABOVE the entry survives with everything else.
1656
+
1657
+ Falls back to the whole-document re-serialise when there is no original text
1658
+ to splice into (a fresh file) or the locator cannot confidently place the
1659
+ edit. That fallback is lossy of comments exactly as before, but never
1660
+ malformed: it is the documented safety net, not a second bug.
1661
+
1662
+ Returns the content alongside whether the write dropped a comment (or
1663
+ trailing comma) the customer's file held -- disclosing that is the
1664
+ non-negotiable floor #182 named, not an optional nicety.
1665
+ """
1666
+ if existing_content is not None:
1667
+ edit = jsonc_splice.locate_configuration_edit(existing_content, splice_index)
1668
+ if edit is not None:
1669
+ if edit.kind == "replace":
1670
+ span = existing_content[edit.start : edit.end]
1671
+ dropped = strip_jsonc(span) != span
1672
+ else:
1673
+ # An append never rewrites any existing span.
1674
+ dropped = False
1675
+ return jsonc_splice.apply_edit(existing_content, edit, entry), dropped
1676
+ dropped = existing_content is not None and strip_jsonc(existing_content) != existing_content
1677
+ return jsonc_splice.pretty_json(document) + "\n", dropped
1678
+
1679
+
1680
+ def strip_jsonc(text: str) -> str:
1681
+ """Strip a leading UTF-8 BOM plus ``//`` / ``/* */`` comments from JSONC
1682
+ text, then drop trailing commas before a closing ``}``/``]``, so the result
1683
+ parses as plain JSON. Scans character by character and tracks string state,
1684
+ so a ``//`` or trailing comma *inside* a quoted string value is never
1685
+ touched."""
1686
+ if text.startswith(jsonc_splice.BOM):
1687
+ text = text[1:]
1688
+
1689
+ out: list[str] = []
1690
+ in_string = False
1691
+ escaped = False
1692
+ i = 0
1693
+ while i < len(text):
1694
+ char = text[i]
1695
+ if in_string:
1696
+ out.append(char)
1697
+ if escaped:
1698
+ escaped = False
1699
+ elif char == "\\":
1700
+ escaped = True
1701
+ elif char == '"':
1702
+ in_string = False
1703
+ i += 1
1704
+ continue
1705
+ if char == '"':
1706
+ in_string = True
1707
+ out.append(char)
1708
+ i += 1
1709
+ elif char == "/" and text[i + 1 : i + 2] == "/":
1710
+ i += 2
1711
+ while i < len(text):
1712
+ if text[i] == "\n":
1713
+ out.append("\n")
1714
+ i += 1
1715
+ break
1716
+ i += 1
1717
+ elif char == "/" and text[i + 1 : i + 2] == "*":
1718
+ i += 2
1719
+ while i < len(text):
1720
+ if text[i] == "*" and text[i + 1 : i + 2] == "/":
1721
+ i += 2
1722
+ break
1723
+ i += 1
1724
+ else:
1725
+ out.append(char)
1726
+ i += 1
1727
+
1728
+ no_comments = "".join(out)
1729
+ result: list[str] = []
1730
+ in_string = False
1731
+ escaped = False
1732
+ i = 0
1733
+ while i < len(no_comments):
1734
+ char = no_comments[i]
1735
+ if in_string:
1736
+ result.append(char)
1737
+ if escaped:
1738
+ escaped = False
1739
+ elif char == "\\":
1740
+ escaped = True
1741
+ elif char == '"':
1742
+ in_string = False
1743
+ i += 1
1744
+ continue
1745
+ if char == '"':
1746
+ in_string = True
1747
+ result.append(char)
1748
+ i += 1
1749
+ continue
1750
+ if char == ",":
1751
+ j = i + 1
1752
+ while j < len(no_comments) and no_comments[j].isspace():
1753
+ j += 1
1754
+ if j < len(no_comments) and no_comments[j] in "}]":
1755
+ i += 1
1756
+ continue # drop the trailing comma
1757
+ result.append(char)
1758
+ i += 1
1759
+ return "".join(result)
1760
+
1761
+
1762
+ def _parse_launch_json_or_default(content: str | None) -> dict[str, Any]:
1763
+ """Parse an existing launch.json, or return a fresh empty document.
1764
+
1765
+ launch.json is JSONC in the wild, not strict JSON: VS Code's own "Add
1766
+ Configuration" template opens with ``//`` comment lines, trailing commas are
1767
+ common, and a Windows-authored file routinely carries a UTF-8 BOM. A strict
1768
+ parse used to reject exactly the file VS Code itself writes, sending the user
1769
+ to hand-edit a "valid" file.
1770
+ """
1771
+ stripped = strip_jsonc(content).strip() if content is not None else ""
1772
+ if stripped == "":
1773
+ return {"version": "0.2.0", "configurations": []}
1774
+
1775
+ try:
1776
+ parsed = json.loads(stripped)
1777
+ except ValueError as err:
1778
+ raise DebugConfigError("Alp: .vscode/launch.json is not valid JSON.") from err
1779
+ if not isinstance(parsed, dict):
1780
+ raise DebugConfigError("Alp: .vscode/launch.json must be a JSON object.")
1781
+
1782
+ version = parsed.get("version")
1783
+ if not (isinstance(version, str) and version.strip()):
1784
+ version = "0.2.0"
1785
+ raw_configs = parsed.get("configurations")
1786
+ configurations = (
1787
+ [c for c in raw_configs if isinstance(c, dict)]
1788
+ if isinstance(raw_configs, list)
1789
+ else []
1790
+ )
1791
+ # {**parsed, version, configurations}: keep the file's own key order, override
1792
+ # the two keys. A dict assignment to an existing key updates it in place,
1793
+ # which is what the Rust's IndexMap insert does.
1794
+ parsed["version"] = version
1795
+ parsed["configurations"] = configurations
1796
+ return parsed
1797
+
1798
+
1799
+ def _configuration_name(configuration: dict[str, Any]) -> str:
1800
+ name = configuration.get("name")
1801
+ if isinstance(name, str) and name.strip():
1802
+ return name
1803
+ raise DebugConfigError("Alp: debug launch draft is missing a valid name.")