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/jsonc_splice.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Locate WHERE inside a launch.json's own raw text a single ``configurations``
|
|
3
|
+
entry lives, so a write can splice just that entry instead of parsing the whole
|
|
4
|
+
document and re-serialising it (tan-cli#182).
|
|
5
|
+
|
|
6
|
+
Port of ``crates/tan-core/src/jsonc_splice.rs``. ``debug_launch`` already
|
|
7
|
+
answers "what does this file MEAN" (``strip_jsonc`` + ``json.loads``); this
|
|
8
|
+
module answers the narrower "where, in the ORIGINAL text, do the characters for
|
|
9
|
+
element N of the top-level ``configurations`` array begin and end". The scan
|
|
10
|
+
mirrors ``strip_jsonc``'s string/comment tracking so the two can never disagree
|
|
11
|
+
about what counts as structure -- but nothing here builds a value; it only
|
|
12
|
+
returns offsets into the caller's own string.
|
|
13
|
+
|
|
14
|
+
The payoff: a caller that copies ``original[:start]`` and ``original[end:]``
|
|
15
|
+
verbatim around a replacement never touches a comment, a trailing comma, or a
|
|
16
|
+
leading BOM sitting outside the edited span -- there is no re-serialisation step
|
|
17
|
+
for those characters to survive.
|
|
18
|
+
|
|
19
|
+
[`locate_configuration_edit`] returns ``None`` whenever the raw text does not
|
|
20
|
+
confidently resolve to a ``"configurations": [ ... ]`` array (missing key, wrong
|
|
21
|
+
shape, any scan mismatch). The caller's documented fallback is the old
|
|
22
|
+
whole-document re-serialise, which is always safe if lossy of comments, so a
|
|
23
|
+
locator miss degrades gracefully instead of risking a malformed write.
|
|
24
|
+
|
|
25
|
+
One deliberate simplification against the Rust: that `Scanner` carries a
|
|
26
|
+
`Vec<(byte, char)>` and converts token index -> byte offset at every boundary,
|
|
27
|
+
because Rust strings are byte-indexed and a multi-byte character would
|
|
28
|
+
otherwise desynchronise the two. Python strings are indexed by code point and
|
|
29
|
+
slice by code point, so index and offset are the same number here and the
|
|
30
|
+
conversion layer has nothing to do. Same semantics, no `byte_at`.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
#: U+FEFF, spelled out rather than embedded: a literal BOM in source is
|
|
40
|
+
#: invisible and the first editor or codec to normalise it away would silently
|
|
41
|
+
#: turn the guard below into a no-op.
|
|
42
|
+
BOM = "\ufeff"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class SpliceEdit:
|
|
47
|
+
"""Where to make the edit, as offsets into the ORIGINAL text.
|
|
48
|
+
|
|
49
|
+
``kind == "replace"``: replace the configuration object spanning
|
|
50
|
+
``[start, end)`` (``start`` is its ``{``, ``end`` one past the matching
|
|
51
|
+
``}``) with the new entry, reindented using ``indent``.
|
|
52
|
+
|
|
53
|
+
``kind == "append"``: insert a new entry immediately after offset ``start``
|
|
54
|
+
(the end of the array's last element, or just past its ``[`` when the array
|
|
55
|
+
is empty). ``needs_leading_comma`` is ``True`` when the element already
|
|
56
|
+
there is not followed by a trailing comma. ``closing_indent`` is set only
|
|
57
|
+
when appending into an array collapsed onto one line (``"configurations":
|
|
58
|
+
[]`` -- VS Code's own stock template, the single most common real input):
|
|
59
|
+
``original`` supplies no line break of its own before the ``]`` there, so
|
|
60
|
+
without it the appended entry's trailing newline drags the ``]`` down to
|
|
61
|
+
column 0 (tan-cli#182 review finding #5).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
kind: str
|
|
65
|
+
start: int
|
|
66
|
+
indent: str
|
|
67
|
+
end: int = 0
|
|
68
|
+
needs_leading_comma: bool = False
|
|
69
|
+
closing_indent: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def locate_configuration_edit(original: str, index: int | None) -> SpliceEdit | None:
|
|
73
|
+
"""Find where the ``index``-th configuration entry lives in ``original``
|
|
74
|
+
(``index is None`` means "no match -- append a fresh entry instead").
|
|
75
|
+
|
|
76
|
+
``index`` counts ONLY object-shaped elements of the top-level
|
|
77
|
+
``configurations`` array, in order -- the same filter
|
|
78
|
+
``parse_launch_json_or_default`` applies before handing the array to
|
|
79
|
+
``create_launch_json_write_plan``, so an index computed against that
|
|
80
|
+
filtered list passes straight through.
|
|
81
|
+
"""
|
|
82
|
+
s = _Scanner(original)
|
|
83
|
+
array_open = _find_configurations_array(s)
|
|
84
|
+
if array_open is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
cursor = array_open + 1
|
|
88
|
+
object_spans: list[tuple[int, int]] = []
|
|
89
|
+
last_element_end: int | None = None
|
|
90
|
+
# Whether the LAST element seen was followed by a comma before `]`, and
|
|
91
|
+
# where that comma left the cursor -- an existing comma already separates
|
|
92
|
+
# "the last element" from "whatever comes next", so appending there reuses
|
|
93
|
+
# it instead of emitting a second one (`,,`).
|
|
94
|
+
trailing_comma = False
|
|
95
|
+
after_trailing_comma = 0
|
|
96
|
+
while True:
|
|
97
|
+
cursor = s.skip_ws_and_comments(cursor)
|
|
98
|
+
if s.char_at(cursor) == "]":
|
|
99
|
+
break
|
|
100
|
+
elem_start = cursor
|
|
101
|
+
is_object = s.char_at(cursor) == "{"
|
|
102
|
+
elem_end = s.skip_value(cursor)
|
|
103
|
+
if elem_end is None:
|
|
104
|
+
return None
|
|
105
|
+
if is_object:
|
|
106
|
+
object_spans.append((elem_start, elem_end))
|
|
107
|
+
last_element_end = elem_end
|
|
108
|
+
cursor = s.skip_ws_and_comments(elem_end)
|
|
109
|
+
char = s.char_at(cursor)
|
|
110
|
+
if char == ",":
|
|
111
|
+
cursor += 1
|
|
112
|
+
# Provisional: disproved below if another element follows -- the
|
|
113
|
+
# next iteration's own comma-or-`]` check overwrites both before
|
|
114
|
+
# they are ever read again.
|
|
115
|
+
trailing_comma = True
|
|
116
|
+
after_trailing_comma = cursor
|
|
117
|
+
elif char == "]":
|
|
118
|
+
trailing_comma = False
|
|
119
|
+
break
|
|
120
|
+
else:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
if index is not None:
|
|
124
|
+
if index >= len(object_spans):
|
|
125
|
+
return None
|
|
126
|
+
start, end = object_spans[index]
|
|
127
|
+
return SpliceEdit(
|
|
128
|
+
kind="replace", start=start, end=end, indent=_indent_before(original, start)
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if last_element_end is not None:
|
|
132
|
+
# A pre-existing trailing comma already separates the last element from
|
|
133
|
+
# our new one, so insert AFTER it -- inserting before would leave the
|
|
134
|
+
# two elements adjacent with no separator and strand the comma as a
|
|
135
|
+
# second, now-orphaned trailing comma of its own.
|
|
136
|
+
if trailing_comma:
|
|
137
|
+
after, needs_leading_comma = after_trailing_comma, False
|
|
138
|
+
else:
|
|
139
|
+
after, needs_leading_comma = last_element_end, True
|
|
140
|
+
indent = (
|
|
141
|
+
_indent_before(original, object_spans[-1][0])
|
|
142
|
+
if object_spans
|
|
143
|
+
else _DEFAULT_INDENT
|
|
144
|
+
)
|
|
145
|
+
return SpliceEdit(
|
|
146
|
+
kind="append",
|
|
147
|
+
start=after,
|
|
148
|
+
indent=indent,
|
|
149
|
+
needs_leading_comma=needs_leading_comma,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Empty array: insert right after the `[`. Indent one level deeper than the
|
|
153
|
+
# array's OWN line (not the flat default), so the entry lines up under
|
|
154
|
+
# whatever indentation width the rest of the file already uses.
|
|
155
|
+
array_line_indent = _indent_before(original, array_open)
|
|
156
|
+
# `cursor` still holds the position of the closing `]` (the loop broke on it
|
|
157
|
+
# before ever entering the element branch): collapsed onto one line (`[]`,
|
|
158
|
+
# no whitespace between the brackets) means `original` supplies no line
|
|
159
|
+
# break for `]` to land on, so supply the array's own indent explicitly. An
|
|
160
|
+
# already-multi-line empty array (`[\n]`) keeps its own formatting.
|
|
161
|
+
closing_indent = array_line_indent if cursor == array_open + 1 else None
|
|
162
|
+
return SpliceEdit(
|
|
163
|
+
kind="append",
|
|
164
|
+
start=array_open + 1,
|
|
165
|
+
indent=f"{array_line_indent} ",
|
|
166
|
+
needs_leading_comma=False,
|
|
167
|
+
closing_indent=closing_indent,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def apply_edit(original: str, edit: SpliceEdit, entry: Any) -> str:
|
|
172
|
+
"""Apply a located edit, inserting ``entry`` (pretty-printed and reindented
|
|
173
|
+
to match where it lands) and returning the full new document. Every
|
|
174
|
+
character of ``original`` outside the edited span is copied through
|
|
175
|
+
unchanged -- that is the whole point: no re-serialisation pass runs over
|
|
176
|
+
them, so a comment, a trailing comma or a BOM elsewhere survives verbatim.
|
|
177
|
+
"""
|
|
178
|
+
pretty = pretty_json(entry)
|
|
179
|
+
eol = _dominant_eol(original)
|
|
180
|
+
if edit.kind == "replace":
|
|
181
|
+
return (
|
|
182
|
+
original[: edit.start]
|
|
183
|
+
+ _reindent(pretty, edit.indent, eol)
|
|
184
|
+
+ original[edit.end :]
|
|
185
|
+
)
|
|
186
|
+
out = original[: edit.start]
|
|
187
|
+
if edit.needs_leading_comma:
|
|
188
|
+
out += ","
|
|
189
|
+
out += eol + edit.indent + _reindent(pretty, edit.indent, eol) + eol
|
|
190
|
+
if edit.closing_indent is not None:
|
|
191
|
+
out += edit.closing_indent
|
|
192
|
+
return out + original[edit.start :]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def pretty_json(value: Any) -> str:
|
|
196
|
+
"""`serde_json::to_string_pretty`'s shape: two-space indent, ``": "`` after
|
|
197
|
+
a key, no space after a comma, and non-ASCII emitted as itself rather than
|
|
198
|
+
escaped (serde does not escape it, and this text lands in a file a human
|
|
199
|
+
reads). ``json.dumps`` with an ``indent`` already defaults the item
|
|
200
|
+
separator to ``","``, so the two agree without spelling it out -- it IS
|
|
201
|
+
spelled out, because the default differs when ``indent`` is ``None`` and a
|
|
202
|
+
reader should not have to know which branch applies.
|
|
203
|
+
"""
|
|
204
|
+
return json.dumps(value, indent=2, separators=(",", ": "), ensure_ascii=False)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _dominant_eol(original: str) -> str:
|
|
208
|
+
"""The line ending already dominant in ``original`` -- ``\\r\\n`` if it holds
|
|
209
|
+
at least one, else ``\\n`` -- so a spliced-in entry's own newlines match its
|
|
210
|
+
neighbours instead of leaving a mixed-EOL file behind on a CRLF-authored
|
|
211
|
+
(Windows-default) launch.json (tan-cli#182 review finding #4).
|
|
212
|
+
"""
|
|
213
|
+
return "\r\n" if "\r\n" in original else "\n"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: A pretty printer starts every nested line 2 spaces deeper than its parent; a
|
|
217
|
+
#: brand-new array element one level under `"configurations": [` (itself one
|
|
218
|
+
#: level under the document root) lands at 4 spaces with no existing sibling to
|
|
219
|
+
#: copy a style from.
|
|
220
|
+
_DEFAULT_INDENT = " "
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _reindent(pretty: str, indent: str, eol: str) -> str:
|
|
224
|
+
"""Re-prefix every line of ``pretty`` AFTER the first with ``indent``,
|
|
225
|
+
joined with ``eol`` rather than a bare ``\\n``. The first line is left
|
|
226
|
+
alone: for a replace it takes the position of the old ``{``, already after
|
|
227
|
+
whatever indentation preceded it; for an append the caller pushes ``indent``
|
|
228
|
+
itself before calling.
|
|
229
|
+
"""
|
|
230
|
+
lines = pretty.split("\n")
|
|
231
|
+
return lines[0] + "".join(f"{eol}{indent}{line}" for line in lines[1:])
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _indent_before(text: str, pos: int) -> str:
|
|
235
|
+
"""The whitespace-only run from the start of ``pos``'s line up to ``pos``,
|
|
236
|
+
if it IS whitespace-only; otherwise [`_DEFAULT_INDENT`]. Matches a
|
|
237
|
+
new/replaced entry's indentation to its neighbours."""
|
|
238
|
+
before = text[:pos]
|
|
239
|
+
line_start = before.rfind("\n") + 1 # -1 + 1 == 0 when there is no newline
|
|
240
|
+
candidate = before[line_start:]
|
|
241
|
+
if candidate and all(c in " \t" for c in candidate):
|
|
242
|
+
return candidate
|
|
243
|
+
return _DEFAULT_INDENT
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _find_configurations_array(s: _Scanner) -> int | None:
|
|
247
|
+
"""Scan the top-level object for a ``"configurations"`` key and return the
|
|
248
|
+
offset of its value's opening ``[``, or ``None`` if the key is absent, is
|
|
249
|
+
not the very next thing after ``:``, or the document does not open with
|
|
250
|
+
``{`` at all (every case the caller treats as "fall back to full
|
|
251
|
+
re-serialise").
|
|
252
|
+
|
|
253
|
+
A SECOND top-level ``"configurations"`` key also returns ``None``: JSON does
|
|
254
|
+
not forbid a duplicate key, and ``json.loads`` (like VS Code's own
|
|
255
|
+
jsonc-parser) resolves one to its LAST occurrence, but this scan would hand
|
|
256
|
+
back the FIRST -- splicing into the array nothing downstream reads, while
|
|
257
|
+
the caller's index (computed against the parsed, last-wins document)
|
|
258
|
+
silently addresses the other one (tan-cli#182 review finding #3).
|
|
259
|
+
"""
|
|
260
|
+
p = s.skip_ws_and_comments(0)
|
|
261
|
+
if s.char_at(p) != "{":
|
|
262
|
+
return None
|
|
263
|
+
p += 1
|
|
264
|
+
found: int | None = None
|
|
265
|
+
while True:
|
|
266
|
+
p = s.skip_ws_and_comments(p)
|
|
267
|
+
char = s.char_at(p)
|
|
268
|
+
if char == "}":
|
|
269
|
+
return found
|
|
270
|
+
if char == ",":
|
|
271
|
+
p += 1
|
|
272
|
+
continue
|
|
273
|
+
if char != '"':
|
|
274
|
+
return None
|
|
275
|
+
key_start = p
|
|
276
|
+
key_end = s.skip_string(p)
|
|
277
|
+
if key_end is None:
|
|
278
|
+
return None
|
|
279
|
+
key_text = s.text[key_start + 1 : key_end - 1]
|
|
280
|
+
p = s.skip_ws_and_comments(key_end)
|
|
281
|
+
if s.char_at(p) != ":":
|
|
282
|
+
return None
|
|
283
|
+
p = s.skip_ws_and_comments(p + 1)
|
|
284
|
+
if key_text == "configurations":
|
|
285
|
+
if found is not None:
|
|
286
|
+
return None
|
|
287
|
+
if s.char_at(p) != "[":
|
|
288
|
+
return None
|
|
289
|
+
found = p
|
|
290
|
+
nxt = s.skip_value(p)
|
|
291
|
+
if nxt is None:
|
|
292
|
+
return None
|
|
293
|
+
p = nxt
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class _Scanner:
|
|
297
|
+
"""Char-offset view over the source text, with the same string/comment
|
|
298
|
+
tracking ``strip_jsonc`` uses -- so the two can never disagree about what
|
|
299
|
+
counts as JSON structure vs quoted text vs a comment."""
|
|
300
|
+
|
|
301
|
+
def __init__(self, text: str) -> None:
|
|
302
|
+
self.text = text
|
|
303
|
+
|
|
304
|
+
def char_at(self, p: int) -> str | None:
|
|
305
|
+
"""The character at ``p``, or ``None`` past the end. Guarded rather than
|
|
306
|
+
sliced blindly: a NEGATIVE index would wrap to the end of the string and
|
|
307
|
+
silently restart the scan there."""
|
|
308
|
+
if 0 <= p < len(self.text):
|
|
309
|
+
return self.text[p]
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
def skip_ws_and_comments(self, p: int) -> int:
|
|
313
|
+
"""Skip whitespace, a stray BOM, and ``//`` / ``/* */`` comments."""
|
|
314
|
+
while True:
|
|
315
|
+
char = self.char_at(p)
|
|
316
|
+
if char is None:
|
|
317
|
+
return p
|
|
318
|
+
if char.isspace() or char == BOM:
|
|
319
|
+
p += 1
|
|
320
|
+
elif char == "/" and self.char_at(p + 1) == "/":
|
|
321
|
+
p += 2
|
|
322
|
+
while (c := self.char_at(p)) is not None:
|
|
323
|
+
p += 1
|
|
324
|
+
if c == "\n":
|
|
325
|
+
break
|
|
326
|
+
elif char == "/" and self.char_at(p + 1) == "*":
|
|
327
|
+
p += 2
|
|
328
|
+
while True:
|
|
329
|
+
c = self.char_at(p)
|
|
330
|
+
if c is None:
|
|
331
|
+
break
|
|
332
|
+
if c == "*" and self.char_at(p + 1) == "/":
|
|
333
|
+
p += 2
|
|
334
|
+
break
|
|
335
|
+
p += 1
|
|
336
|
+
else:
|
|
337
|
+
return p
|
|
338
|
+
|
|
339
|
+
def skip_string(self, p: int) -> int | None:
|
|
340
|
+
"""Skip a JSON string starting at its opening ``"``; returns the offset
|
|
341
|
+
just past the closing ``"``, or ``None`` if it never closes."""
|
|
342
|
+
q = p + 1
|
|
343
|
+
while True:
|
|
344
|
+
char = self.char_at(q)
|
|
345
|
+
if char is None:
|
|
346
|
+
return None
|
|
347
|
+
if char == "\\":
|
|
348
|
+
q += 2
|
|
349
|
+
elif char == '"':
|
|
350
|
+
return q + 1
|
|
351
|
+
else:
|
|
352
|
+
q += 1
|
|
353
|
+
|
|
354
|
+
def skip_balanced(self, p: int) -> int | None:
|
|
355
|
+
"""Skip a balanced ``{...}``/``[...]`` region starting at its opening
|
|
356
|
+
bracket, tracking nested brackets/strings/comments so one inside a
|
|
357
|
+
string or comment is never mistaken for structure. ``None`` if it never
|
|
358
|
+
balances."""
|
|
359
|
+
depth = 0
|
|
360
|
+
q = p
|
|
361
|
+
while True:
|
|
362
|
+
char = self.char_at(q)
|
|
363
|
+
if char is None:
|
|
364
|
+
return None
|
|
365
|
+
if char == '"':
|
|
366
|
+
nxt = self.skip_string(q)
|
|
367
|
+
if nxt is None:
|
|
368
|
+
return None
|
|
369
|
+
q = nxt
|
|
370
|
+
elif char == "/" and self.char_at(q + 1) in ("/", "*"):
|
|
371
|
+
q = self.skip_ws_and_comments(q)
|
|
372
|
+
elif char in "{[":
|
|
373
|
+
depth += 1
|
|
374
|
+
q += 1
|
|
375
|
+
elif char in "}]":
|
|
376
|
+
depth -= 1
|
|
377
|
+
q += 1
|
|
378
|
+
if depth == 0:
|
|
379
|
+
return q
|
|
380
|
+
else:
|
|
381
|
+
q += 1
|
|
382
|
+
|
|
383
|
+
def skip_value(self, p: int) -> int | None:
|
|
384
|
+
"""Skip one JSON value (string/object/array/number/true/false/null);
|
|
385
|
+
returns the offset just past it, or ``None`` on a value that never
|
|
386
|
+
terminates."""
|
|
387
|
+
char = self.char_at(p)
|
|
388
|
+
if char is None:
|
|
389
|
+
return None
|
|
390
|
+
if char == '"':
|
|
391
|
+
return self.skip_string(p)
|
|
392
|
+
if char in "{[":
|
|
393
|
+
return self.skip_balanced(p)
|
|
394
|
+
# number / true / false / null: run until a structural delimiter. A `/`
|
|
395
|
+
# is checked separately so a comment with no preceding space
|
|
396
|
+
# (`nullfoo//c`) is never folded into it.
|
|
397
|
+
q = p
|
|
398
|
+
while (c := self.char_at(q)) is not None:
|
|
399
|
+
if c in ",}]" or c.isspace():
|
|
400
|
+
break
|
|
401
|
+
if c == "/" and self.char_at(q + 1) in ("/", "*"):
|
|
402
|
+
break
|
|
403
|
+
q += 1
|
|
404
|
+
return q if q != p else None
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The ``.alp/debug-launch-provenance.json`` sidecar ``tan debug-config``
|
|
3
|
+
reads and writes alongside ``.vscode/launch.json`` -- a content-hash record
|
|
4
|
+
of which ``configFiles``/``setupCommands`` list entries a PRIOR
|
|
5
|
+
tan-authored write itself produced, so the next run's merge
|
|
6
|
+
(``debug_launch._merge_list_by_identity``) can tell "tan wrote this, safe to
|
|
7
|
+
update or retract" apart from "the customer's, leave it alone" without
|
|
8
|
+
relying on POSITION. tan-cli#518, the gap tan-cli#489's own docstring names
|
|
9
|
+
as a "Known, accepted limitation" (``debug_launch.py``'s
|
|
10
|
+
``_merge_list_by_identity``).
|
|
11
|
+
|
|
12
|
+
tan-cli#1020 review widened this sidecar to a THIRD field, ``loadFiles``
|
|
13
|
+
(tan-cli#945) -- the same content-hash record, read by the same
|
|
14
|
+
:meth:`LaunchProvenance.hashes_for`, but consumed by a DIFFERENT merge rule
|
|
15
|
+
(``debug_launch._merge_load_files``, not ``_merge_list_by_identity``): a
|
|
16
|
+
``loadFiles`` this run cannot prove it wrote is left untouched WHOLESALE
|
|
17
|
+
(never appended-to, unlike ``configFiles``/``setupCommands``), because it
|
|
18
|
+
names one deliberate artefact list -- possibly an explicit ``[]`` for
|
|
19
|
+
attach-only -- not a set of independently-owned entries. This module itself
|
|
20
|
+
needed no change for that: `hashes_for`/`updated`/`record` are already
|
|
21
|
+
generic over the field name.
|
|
22
|
+
|
|
23
|
+
## Why content hash, and why a sidecar at all
|
|
24
|
+
|
|
25
|
+
The design decision (tan-cli#518, 2026-08-28) rules out an in-file marker:
|
|
26
|
+
``launch.json`` is schema-validated by VS Code against the debug adapter's
|
|
27
|
+
own contract, and an unknown property inside a ``configurations[]`` entry
|
|
28
|
+
draws a permanent squiggle in the customer's editor -- "that warning is
|
|
29
|
+
ours, ignore it" is not something a tool should ever require of its own
|
|
30
|
+
output. So the record lives beside ``launch.json``, in ``.alp/`` (the same
|
|
31
|
+
directory ``tan init``'s own ``sdk-path`` pointer already occupies --
|
|
32
|
+
``init_cmd.py``, ``bootstrap_cmd._project_pin_file``), never inside it.
|
|
33
|
+
``launch.json`` itself is byte-for-byte unaffected by this module: no new
|
|
34
|
+
key, no comment, nothing ``jsonc_splice``'s span-preserving edits need to
|
|
35
|
+
know about.
|
|
36
|
+
|
|
37
|
+
Position was the ORIGINAL provenance proxy (tan-cli#489's anchor-relative
|
|
38
|
+
placement) and it has exactly one failure mode: a customer's own hand-added
|
|
39
|
+
entry that happens to fall into the position tan is about to reuse gets
|
|
40
|
+
silently overwritten, indistinguishable from tan's own prior output sitting
|
|
41
|
+
there. A NAME would have the same flaw one layer up -- ``configFiles``
|
|
42
|
+
entries have no name at all, and ``setupCommands``' own ``text`` field is
|
|
43
|
+
exactly the identity ``_list_item_identity`` already matches by (matching by
|
|
44
|
+
identity already never reaches position; only an UNMATCHED entry does).
|
|
45
|
+
Content hash is the one thing that is stable across reordering and a
|
|
46
|
+
reformatting round-trip (see ``content_hash`` below) yet changes the instant
|
|
47
|
+
a human -- or a formatter with different defaults -- touches the actual
|
|
48
|
+
value.
|
|
49
|
+
|
|
50
|
+
## The asymmetry this whole module exists to preserve
|
|
51
|
+
|
|
52
|
+
Every desync degrades the SAME direction: a missing file, corrupt JSON, a
|
|
53
|
+
schema this build does not recognise, an entry whose hash was never
|
|
54
|
+
recorded, a stale record left over from a run that resolved something
|
|
55
|
+
different -- all of them read as "not tan's", never as "tan's, go ahead".
|
|
56
|
+
:func:`load` never raises; every failure returns :func:`empty`, the record
|
|
57
|
+
that owns nothing. A customer who deletes
|
|
58
|
+
``.alp/debug-launch-provenance.json`` outright gets exactly that: tan
|
|
59
|
+
forgets what it wrote and starts leaving every existing list entry alone
|
|
60
|
+
(``debug_launch._merge_list_by_identity``'s position-based pass degrades to
|
|
61
|
+
append-only without a confirmed hash) -- a mildly stale entry the customer
|
|
62
|
+
can delete in one keystroke, never a deleted customer edit. The SIDECAR
|
|
63
|
+
re-establishes itself from the very next write, so the MECHANISM's
|
|
64
|
+
degradation is one run, not permanent -- but a list entry it left stranded
|
|
65
|
+
during that one run (appended beside a value it could not prove was its
|
|
66
|
+
own) is not retroactively cleaned up once the sidecar heals; it stays in
|
|
67
|
+
``launch.json`` until the customer deletes it themselves. See
|
|
68
|
+
``debug_launch.sdk_identity_stranded_appends`` (surfaced by
|
|
69
|
+
``debug_config_cmd.py`` as ``debug-config.sdk-identity-appended``) for the
|
|
70
|
+
disclosure that names it rather than leaving it silent.
|
|
71
|
+
|
|
72
|
+
**``loadFiles`` heals the same way for the same reason, but only from ONE
|
|
73
|
+
specific starting point** (tan-cli#1020 re-review): a sidecar loss re-derives
|
|
74
|
+
provenance the instant the field itself is next observed absent from the
|
|
75
|
+
existing entry -- the pre-#945 upgrade shape, and the only case where there
|
|
76
|
+
is provably nothing to protect (``debug_launch._merge_configuration``'s
|
|
77
|
+
key-absent branch records what it just wrote, exactly like a brand-new
|
|
78
|
+
entry). A sidecar lost while the KEY WAS ALREADY PRESENT -- a genuinely
|
|
79
|
+
tan-authored value, or a customer's own -- cannot re-derive provenance from
|
|
80
|
+
observation alone, because ``_merge_load_files``'s protect branch (unlike
|
|
81
|
+
``configFiles``/``setupCommands``'s append) makes no write of its own to
|
|
82
|
+
attribute: recording ownership of an untouched value the sidecar merely
|
|
83
|
+
forgot would be indistinguishable, to the very next run, from recording
|
|
84
|
+
ownership of a customer's own untouched value, which is the exact silent-
|
|
85
|
+
overwrite failure tan-cli#518/#1020's protect rule exists to prevent. So
|
|
86
|
+
that narrower case stays protected -- and disclosed, every run, via
|
|
87
|
+
``debug-config.load-files-preserved`` -- until the customer's own edit (or a
|
|
88
|
+
value that happens to already match the fresh resolution) lets it agree
|
|
89
|
+
again; it does not "heal" the way an appended `configFiles` entry's
|
|
90
|
+
provenance does.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
from __future__ import annotations
|
|
94
|
+
|
|
95
|
+
import hashlib
|
|
96
|
+
import json
|
|
97
|
+
from dataclasses import dataclass, field
|
|
98
|
+
from pathlib import Path
|
|
99
|
+
from typing import Any
|
|
100
|
+
|
|
101
|
+
#: Sidecar schema version. A `load` that sees anything else treats the whole
|
|
102
|
+
#: file as unrecognised -- the safe direction for a shape a future (or past)
|
|
103
|
+
#: build cannot confidently parse; see `load`.
|
|
104
|
+
SCHEMA_VERSION = 1
|
|
105
|
+
|
|
106
|
+
#: `<workspace_root>/.alp/debug-launch-provenance.json` -- beside `tan
|
|
107
|
+
#: init`'s own `.alp/sdk-path` (`init_cmd.py`, `bootstrap_cmd._project_pin_
|
|
108
|
+
#: file`), never inside `.vscode/launch.json` itself.
|
|
109
|
+
SIDECAR_RELATIVE_PATH = (".alp", "debug-launch-provenance.json")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def sidecar_path(workspace_root: str | Path) -> Path:
|
|
113
|
+
"""The sidecar path for a workspace root, following the same
|
|
114
|
+
`.alp/<file>` convention `tan init`/`tan sdk` already use."""
|
|
115
|
+
return Path(workspace_root).joinpath(*SIDECAR_RELATIVE_PATH)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def content_hash(value: Any) -> str:
|
|
119
|
+
"""A stable hash of a `configFiles`/`setupCommands` list ENTRY's
|
|
120
|
+
semantic content. `json.dumps(..., sort_keys=True)` canonicalises key
|
|
121
|
+
order before hashing, so re-serialising an unchanged `setupCommands`
|
|
122
|
+
dict with a different key order (an external formatter, a hand-edit
|
|
123
|
+
that only reordered `text`/`ignoreFailures`) hashes IDENTICAL to what
|
|
124
|
+
tan itself wrote, and is never mistaken for a customer edit -- the exact
|
|
125
|
+
property the issue names: "re-serialisation with different key order or
|
|
126
|
+
whitespace must not read as an edit". Whitespace never reaches the hash
|
|
127
|
+
at all, because this hashes the PARSED value, not raw text.
|
|
128
|
+
"""
|
|
129
|
+
canonical = json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
130
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class LaunchProvenance:
|
|
135
|
+
"""The parsed sidecar: per-configuration-name, per-list-field sets of
|
|
136
|
+
content hashes tan itself wrote the last time a run touched that field.
|
|
137
|
+
|
|
138
|
+
Immutable from the outside -- :meth:`updated` is the only way a caller
|
|
139
|
+
(`debug_launch.create_launch_json_write_plan`) ever changes one, and it
|
|
140
|
+
always returns a NEW record rather than mutating `self`, so a
|
|
141
|
+
`LaunchJsonWritePlan` never rewrites the `LaunchProvenance` its own
|
|
142
|
+
caller is still holding a reference to.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
_by_config: dict[str, dict[str, list[str]]] = field(default_factory=dict)
|
|
146
|
+
|
|
147
|
+
def hashes_for(self, configuration_name: str, list_field: str) -> frozenset[str]:
|
|
148
|
+
"""The hashes tan recorded for `list_field` (`configFiles` /
|
|
149
|
+
`setupCommands`) the last time it wrote `configuration_name` --
|
|
150
|
+
empty when the configuration, the field, or the whole sidecar was
|
|
151
|
+
never recorded (or could not be read; see `load`). Empty is the
|
|
152
|
+
safe default everywhere this is called from: it is exactly the
|
|
153
|
+
"nothing is ours" set."""
|
|
154
|
+
return frozenset(self._by_config.get(configuration_name, {}).get(list_field, ()))
|
|
155
|
+
|
|
156
|
+
def record(self, configuration_name: str, list_field: str, entries: list[Any]) -> None:
|
|
157
|
+
"""Replace `list_field`'s recorded hash set for `configuration_name`
|
|
158
|
+
with the hashes of exactly `entries` -- MUTATES `self`; only
|
|
159
|
+
:meth:`updated` (which always operates on a fresh copy) calls this
|
|
160
|
+
directly."""
|
|
161
|
+
config = self._by_config.setdefault(configuration_name, {})
|
|
162
|
+
config[list_field] = [content_hash(entry) for entry in entries]
|
|
163
|
+
|
|
164
|
+
def updated(
|
|
165
|
+
self, configuration_name: str, owned_entries: dict[str, list[Any]]
|
|
166
|
+
) -> LaunchProvenance:
|
|
167
|
+
"""A NEW record equal to `self` except `configuration_name`'s fields
|
|
168
|
+
named in `owned_entries` are replaced by the hashes of exactly
|
|
169
|
+
those entries -- the list entries THIS run identified as
|
|
170
|
+
tan-authored (matched, positionally placed, or freshly appended;
|
|
171
|
+
never an entry `debug_launch._merge_list_by_identity`'s pass 3
|
|
172
|
+
left untouched, and never a field the run did not touch at all --
|
|
173
|
+
that field, absent from `owned_entries`, keeps whatever `self`
|
|
174
|
+
already had for it).
|
|
175
|
+
|
|
176
|
+
A field this run DID touch is fully REPLACED, never merged with its
|
|
177
|
+
own stale entries: an entry that vanishes from this run's draft
|
|
178
|
+
resolution must not leave a phantom hash behind for something
|
|
179
|
+
unrelated to accidentally match against later. This is the
|
|
180
|
+
pure-functional twin of :meth:`record` -- `create_launch_json_write_
|
|
181
|
+
plan` never mutates the `provenance` object a caller handed it.
|
|
182
|
+
"""
|
|
183
|
+
result = LaunchProvenance(
|
|
184
|
+
{name: dict(fields) for name, fields in self._by_config.items()}
|
|
185
|
+
)
|
|
186
|
+
for list_field, entries in owned_entries.items():
|
|
187
|
+
result.record(configuration_name, list_field, entries)
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def to_json(self) -> dict[str, Any]:
|
|
191
|
+
return {
|
|
192
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
193
|
+
"configurations": {
|
|
194
|
+
name: dict(fields) for name, fields in self._by_config.items() if fields
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def empty() -> LaunchProvenance:
|
|
200
|
+
"""The record that owns nothing -- what every unreadable, missing, or
|
|
201
|
+
schema-unrecognised sidecar degrades to. tan-cli#518's whole point: a
|
|
202
|
+
desync must read as "not tan's", never as "everything is tan's"."""
|
|
203
|
+
return LaunchProvenance()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def load(content: str | None) -> LaunchProvenance:
|
|
207
|
+
"""Parse a sidecar's raw text into a :class:`LaunchProvenance`. Never
|
|
208
|
+
raises -- anything this cannot confidently parse (`None`, empty, not
|
|
209
|
+
JSON, not an object, a `configurations` that is not an object, a
|
|
210
|
+
`schemaVersion` this build does not recognise, or a per-field value that
|
|
211
|
+
is not a list of strings) returns :func:`empty` rather than guessing,
|
|
212
|
+
per this module's own asymmetry (see the module docstring). A caller
|
|
213
|
+
that could not even READ the file (permission denied, an I/O error) is
|
|
214
|
+
expected to pass `None` here for exactly the same reason -- this
|
|
215
|
+
function draws no distinction between "absent" and "unreadable"; both
|
|
216
|
+
mean "nothing is ours".
|
|
217
|
+
"""
|
|
218
|
+
if not content:
|
|
219
|
+
return empty()
|
|
220
|
+
try:
|
|
221
|
+
document = json.loads(content)
|
|
222
|
+
except json.JSONDecodeError:
|
|
223
|
+
return empty()
|
|
224
|
+
if not isinstance(document, dict):
|
|
225
|
+
return empty()
|
|
226
|
+
if document.get("schemaVersion") != SCHEMA_VERSION:
|
|
227
|
+
return empty()
|
|
228
|
+
configurations = document.get("configurations")
|
|
229
|
+
if not isinstance(configurations, dict):
|
|
230
|
+
return empty()
|
|
231
|
+
by_config: dict[str, dict[str, list[str]]] = {}
|
|
232
|
+
for name, fields in configurations.items():
|
|
233
|
+
if not isinstance(name, str) or not isinstance(fields, dict):
|
|
234
|
+
continue
|
|
235
|
+
clean_fields: dict[str, list[str]] = {}
|
|
236
|
+
for field_name, hashes in fields.items():
|
|
237
|
+
if (
|
|
238
|
+
isinstance(field_name, str)
|
|
239
|
+
and isinstance(hashes, list)
|
|
240
|
+
and all(isinstance(h, str) for h in hashes)
|
|
241
|
+
):
|
|
242
|
+
clean_fields[field_name] = hashes
|
|
243
|
+
if clean_fields:
|
|
244
|
+
by_config[name] = clean_fields
|
|
245
|
+
return LaunchProvenance(by_config)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def render(record: LaunchProvenance) -> str:
|
|
249
|
+
"""The sidecar's own pretty-printed bytes, trailing newline included --
|
|
250
|
+
the same two-space-indent shape `jsonc_splice.pretty_json` uses for
|
|
251
|
+
`launch.json`'s own fresh-file fallback, so a customer who opens either
|
|
252
|
+
file in `.alp`/`.vscode` sees the same formatting convention. Unlike
|
|
253
|
+
`launch.json`, this file is tan's own bookkeeping -- there is no
|
|
254
|
+
customer content in it to preserve byte-for-byte, so it is always a
|
|
255
|
+
plain whole-file re-serialise; nothing here ever needs `jsonc_splice`.
|
|
256
|
+
"""
|
|
257
|
+
return json.dumps(record.to_json(), indent=2, sort_keys=True) + "\n"
|