tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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"