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.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
tan/core/debug_launch.py
ADDED
|
@@ -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.")
|