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,565 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`~/.alp/sdk-defaults.json` -- the origin-keyed sibling of the single
3
+ machine-global `~/.alp/sdk-default` pointer (tan-cli#466, stage 2 of
4
+ tan-cli#464).
5
+
6
+ **The problem stage 1 (tan-cli#464) left open.** `~/.alp/sdk-default` is one
7
+ file, last-writer-wins across every project that ever relocates a checkout on
8
+ this host: bootstrap project A, then bootstrap project B, and A's very next
9
+ `tan build` silently resolves B's checkout through the `globalDefault` tier.
10
+ Stage 1 taught every ladder to DISCLOSE that (`sdk.global-default-foreign-
11
+ project`), which stops the silence but not the wrong answer -- A still builds
12
+ against B's SDK, now with a warning attached.
13
+
14
+ **The fix.** `tan bootstrap` records `origin -> sdkPath` in this registry,
15
+ where `origin` is the same absolute project root stage 1 already computes as
16
+ `writtenFor` -- the directory bootstrap ran in. `sdk_discovery.resolve_sdk_tiered`
17
+ then picks the DEEPEST registry key that CONTAINS the caller's
18
+ `workspace_root`, using the same containment test
19
+ (`sdk_discovery._workspace_under`) stage 1 already uses to decide "foreign". This is
20
+ NOT zero filesystem probing (an earlier version of this docstring, and of
21
+ `changelog.d/466.fixed.md`, claimed it was, and was wrong -- caught in review,
22
+ #904).
23
+
24
+ **What actually touches the filesystem, and how much (corrected a second time
25
+ in review, #904 second round -- the FIRST correction above was itself
26
+ wrong on two counts, and is superseded by this paragraph, not by the one
27
+ above it).** `covers` (`sdk_discovery._workspace_under`) runs once per registry
28
+ entry, unconditionally -- it IS the filter. `has_loader_script` and
29
+ `resolve_origin` (the ranking) run ONLY for a candidate that already passed
30
+ `covers`, i.e. once per COVERING entry, not once per registry entry.
31
+ Measured directly (call counts, not syscalls): a 201-entry registry with a
32
+ single covering entry among 200 non-covering siblings makes 201 `covers`
33
+ calls and exactly 1 `has_loader_script` call and 1 `resolve_origin` call --
34
+ "each candidate" was true only of `covers`.
35
+
36
+ The registry-size claim was ALSO wrong: resolution cost is
37
+ **O(registry size x path depth), not O(registry size) alone** -- every
38
+ `covers`/`resolve_origin` call resolves a real path, and `Path.resolve()`
39
+ `lstat`s every path COMPONENT, so a deeper caller costs more at the same
40
+ registry size. Measured (counting `os.lstat` calls, real directories, the
41
+ production `sdk_discovery._workspace_under`/`_has_loader_script`/
42
+ `_resolved_origin_depth_key` triple, 21 registry entries that ALL cover the
43
+ workspace -- the worst case, nothing skipped by `covers` or
44
+ `has_loader_script`): appending 20 extra path components to the SAME
45
+ workspace, at an unchanged registry size, costs exactly **420** more
46
+ `lstat` calls (21 entries x 1 workspace-resolving call site -- `covers`,
47
+ the only one of the two whose argument the measurement deepens -- x 20
48
+ components a component-`lstat`ing `Path.resolve()` now walks through).
49
+ `resolve_origin` resolves the *origin*, not the workspace, and the
50
+ measurement never deepens the origin, so it contributes zero to the delta
51
+ -- caught in review, #904 final round, major: an earlier revision of this
52
+ paragraph double-counted `resolve_origin` into the factorization (21 x 2 x
53
+ 20 = 840) despite the measured delta being 420, and a re-derivation at a
54
+ different base depth (1,050 -> 1,470, same `420` delta) confirmed 420, not
55
+ 840, is correct. The delta is reported ALONE, not alongside a base-count
56
+ absolute, because the absolute moves with the base depth chosen for the
57
+ measurement and an earlier revision of this paragraph cited one (**1,092**
58
+ vs **1,512**) without saying what that base depth was -- caught in review,
59
+ #904 third round, nit: an independent re-derivation at a different (but
60
+ equally unstated) base depth landed on **1,050**/**1,470**, the same `420`
61
+ delta but a different pair of absolutes, for no reason a reader of either
62
+ number alone could tell. Cite neither this number nor any other syscall
63
+ count for this function without also stating the registry shape (size, how
64
+ many entries cover the caller) and the base path depth it was measured at --
65
+ both factors independently move any ABSOLUTE count; the delta above is the
66
+ one number in this paragraph that does not need one, because it was measured
67
+ as a difference at two path depths under the same registry shape and cancels
68
+ out.
69
+
70
+ What IS true, and what the issue actually promised, is that there is no
71
+ directory WALK -- the candidate set is closed (only directories a real
72
+ `tan bootstrap` explicitly ran in can ever appear as a key), so resolution
73
+ cost does not grow with how deep the caller's workspace sits BELOW its
74
+ covering origin, only with the registry's own size and the ABSOLUTE depth of
75
+ the paths involved. A's subdirectory then resolves A's own entry before the
76
+ legacy pointer is ever consulted, at any depth below its own origin,
77
+ regardless of which project bootstrapped last.
78
+
79
+ The legacy single pointer is NOT retired. `tan bootstrap` keeps writing it
80
+ unconditionally, for skew safety: an old tan that predates this file reads
81
+ only `~/.alp/sdk-default` and never opens this one, so a mixed-version fleet
82
+ degrades to stage 1's disclosed-but-wrong behaviour, never to a hard failure.
83
+ A new tan consults the registry FIRST and falls back to the legacy pointer
84
+ only when no entry here covers the caller -- which is also when the stage-1
85
+ foreign-project warning still fires, now for a genuinely unregistered caller
86
+ rather than for every non-last bootstrap on the host.
87
+
88
+ **A pure `tan.core` module, deliberately.** The functions that need
89
+ filesystem judgement calls (whether one path contains another; whether an
90
+ `sdkPath` still names a real checkout; what a raw origin string resolves to)
91
+ are `sdk_discovery._workspace_under`, `sdk_discovery._has_loader_script`, and
92
+ `sdk_discovery._resolved_origin_depth_key` -- injected into `deepest_covering_entry`
93
+ as callables rather than imported, so this module stays free of any
94
+ `tan.commands.*` import, the same convention every other `tan/core` module
95
+ here already follows. `sdk_discovery.py` and `bootstrap_cmd.py` both import
96
+ this module instead of duplicating its parse/format logic.
97
+ """
98
+ from __future__ import annotations
99
+
100
+ import json
101
+ import re
102
+ from pathlib import Path, PurePosixPath, PureWindowsPath
103
+ from typing import Any, Callable
104
+
105
+ #: Both wire shapes this registry's `updatedAt` can carry --
106
+ #: `generated_at_iso`/`wall_clock_iso`'s seconds form (`...:00Z`) and
107
+ #: milliseconds form (`...:00.000Z`) -- sort correctly against EACH OTHER
108
+ #: (millis form always sorts later within the same second, since `.` <
109
+ #: any digit... except it doesn't: `'Z'` (0x5A) sorts AFTER `'.'` (0x2E),
110
+ #: so a bare seconds-precision stamp outranks a same-second millis stamp as
111
+ #: a raw string, backwards from wall-clock order). Only a HAND-EDITED
112
+ #: registry can produce this today -- `with_entry`'s one production caller
113
+ #: always writes `millis=True` -- but this module explicitly anticipates
114
+ #: hand-edited registries (`parse_registry_updated_at`'s own docstring), so
115
+ #: the tie-break normalises rather than leaving mixed precision undefined.
116
+ _SECONDS_PRECISION_UPDATED_AT = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})Z$")
117
+
118
+
119
+ def _comparable_updated_at(value: str) -> str:
120
+ """`value`, widened to millisecond precision if it is a bare
121
+ seconds-precision ISO-8601 stamp, so `>` on the result orders by actual
122
+ wall-clock recency instead of by which precision a hand-edited entry
123
+ happened to use. Anything that is not exactly that seconds-precision
124
+ shape -- already-millis, empty (`parse_registry_updated_at`'s
125
+ no-timestamp default), or not a recognisable stamp at all -- passes
126
+ through unchanged: this only closes the one concrete mis-order
127
+ (review, #904 final round, nit), it does not attempt to validate or
128
+ reformat arbitrary hand-edited garbage."""
129
+ match = _SECONDS_PRECISION_UPDATED_AT.match(value)
130
+ return f"{match.group(1)}.000Z" if match else value
131
+
132
+ #: `~/.alp/<REGISTRY_FILENAME>` -- named distinctly from the legacy
133
+ #: `sdk-default` (singular, no extension) so a directory listing of `~/.alp`
134
+ #: cannot mistake one file for the other, and so a plain grep for
135
+ #: `sdk-default` (the legacy pointer's own name) never silently matches this
136
+ #: file too.
137
+ REGISTRY_FILENAME = "sdk-defaults.json"
138
+
139
+
140
+ def registry_path(home_alp_dir: Path) -> Path:
141
+ """`<home_alp_dir>/sdk-defaults.json`. `home_alp_dir` is the caller's own
142
+ `~/.alp` (`sdk_discovery._home_alp_dir()`), passed in rather than recomputed
143
+ here -- this module has no opinion on `HOME`/`USERPROFILE` resolution,
144
+ exactly like it has no opinion on filesystem containment."""
145
+ return home_alp_dir / REGISTRY_FILENAME
146
+
147
+
148
+ def parse_registry(raw: str | None) -> dict[str, str]:
149
+ """`origin -> sdkPath`, best-effort. `raw` is the file's text, or `None`
150
+ when it could not be read at all (missing, permission-denied, non-UTF-8 --
151
+ `sdk_discovery._read_file`'s own contract).
152
+
153
+ Every failure shape degrades to `{}`, the empty registry -- a truncated
154
+ write from a concurrent `tan bootstrap`, a hand-edited syntax error, a
155
+ JSON array instead of an object, a value that is not itself an object, an
156
+ `sdkPath` that is missing or not a string. `{}` is exactly what "no
157
+ registry file at all" already means to every caller, so a malformed
158
+ registry degrades to the legacy single pointer -- never to a crash, and
159
+ never to a partial/best-guess reading of a corrupt file.
160
+ """
161
+ if raw is None:
162
+ return {}
163
+ try:
164
+ parsed = json.loads(raw)
165
+ except ValueError:
166
+ return {}
167
+ if not isinstance(parsed, dict):
168
+ return {}
169
+ result: dict[str, str] = {}
170
+ for origin, entry in parsed.items():
171
+ if not isinstance(origin, str) or not origin:
172
+ continue
173
+ sdk_path = entry.get("sdkPath") if isinstance(entry, dict) else None
174
+ if isinstance(sdk_path, str) and sdk_path:
175
+ result[origin] = sdk_path
176
+ return result
177
+
178
+
179
+ def parse_registry_updated_at(raw: str | None) -> dict[str, str]:
180
+ """`origin -> updatedAt`, the recency companion of `parse_registry` --
181
+ feeds `deepest_covering_entry`'s tie-break (review, #904 second round,
182
+ major: two origins that resolve to the SAME directory tie on depth, and
183
+ `depth > best_depth` alone keeps whichever one the sorted-key loop visits
184
+ first, i.e. the lexicographically smallest raw origin -- not the more
185
+ recent bootstrap).
186
+
187
+ A SEPARATE function rather than widening `parse_registry`'s own return
188
+ shape, for the same reason `load_raw` is separate from it: `parse_registry`
189
+ is the flattened `origin -> sdkPath` view several callers already depend
190
+ on staying exactly that shape, and every one of its existing callers has
191
+ no use for a timestamp.
192
+
193
+ Degrades exactly like `parse_registry` -- `{}` for a missing/malformed
194
+ file, and an origin whose ENTRY is not itself a dict-shaped object is
195
+ DROPPED, the identical shape `parse_registry` gives it (a bare string
196
+ value, `{"/proj/a": "/sdk/a"}`, is not an entry either function
197
+ recognises). Within a dict-shaped entry, a missing or non-string
198
+ `updatedAt` degrades to `""` instead -- an entry with no timestamp
199
+ (written by a pre-tie-break tan, or hand-edited) must still be RANKABLE
200
+ by depth, it only forfeits ties -- `""` sorts before every real
201
+ ISO-8601 stamp, so it never wins one.
202
+ """
203
+ if raw is None:
204
+ return {}
205
+ try:
206
+ parsed = json.loads(raw)
207
+ except ValueError:
208
+ return {}
209
+ if not isinstance(parsed, dict):
210
+ return {}
211
+ result: dict[str, str] = {}
212
+ for origin, entry in parsed.items():
213
+ if not isinstance(origin, str) or not origin:
214
+ continue
215
+ if not isinstance(entry, dict):
216
+ continue
217
+ updated_at = entry.get("updatedAt")
218
+ result[origin] = updated_at if isinstance(updated_at, str) else ""
219
+ return result
220
+
221
+
222
+ def load_raw(raw: str | None) -> dict[str, Any]:
223
+ """The WRITE side's counterpart to `parse_registry`: the registry's own
224
+ parsed-JSON shape, untouched, or `{}` on any read/parse failure (missing,
225
+ truncated, not even an object).
226
+
227
+ Deliberately NOT `parse_registry`'s flattened `origin -> sdkPath` view --
228
+ that view already discards the per-entry object wrapper
229
+ (`{"sdkPath": ..., "updatedAt": ...}`) an entry is stored as, so a writer
230
+ that read the FLATTENED view, patched one key, and wrote it back would
231
+ silently downgrade every OTHER origin's entry to a bare string the next
232
+ read still tolerates (`parse_registry` accepts only dict-shaped entries)
233
+ but which no longer round-trips its own wrapper. `with_entry` is written
234
+ against THIS shape for that reason.
235
+ """
236
+ if raw is None:
237
+ return {}
238
+ try:
239
+ parsed = json.loads(raw)
240
+ except ValueError:
241
+ return {}
242
+ return parsed if isinstance(parsed, dict) else {}
243
+
244
+
245
+ def is_absolute_either_platform(value: str) -> bool:
246
+ """Mirrors `sdk_discovery._pointer_written_for`'s own cross-platform absolute
247
+ check, for the same reason: this registry is one file shared by every
248
+ `tan` on the host, and a bare `Path(value).is_absolute()` is answered by
249
+ whichever pathlib flavour the READING platform picked. A relative,
250
+ empty, or drive-relative origin (a hand-edited registry, or a future
251
+ writer bug) is rejected under BOTH flavours rather than accepted under
252
+ whichever one the reader happens to be running.
253
+
254
+ PUBLIC (no leading underscore) because `sdk_removal.removal_would_take_out`
255
+ needs the identical judgement before it will consult the filesystem about a
256
+ stored value, and a cross-module import of a private name is a worse
257
+ dependency than an exported one (tan-cli#1053 review)."""
258
+ return PurePosixPath(value).is_absolute() or PureWindowsPath(value).is_absolute()
259
+
260
+
261
+ def deepest_covering_entry(
262
+ registry: dict[str, str],
263
+ workspace_root: Path,
264
+ *,
265
+ covers: Callable[[Path, str], bool],
266
+ has_loader_script: Callable[[Path], bool],
267
+ resolve_origin: Callable[[str], str],
268
+ updated_at: dict[str, str] | None = None,
269
+ ) -> tuple[str, str] | None:
270
+ """The deepest `(origin, sdkPath)` entry whose `origin` CONTAINS
271
+ `workspace_root` (`covers`) and whose `sdkPath` still resolves
272
+ (`has_loader_script`), or `None` when nothing qualifies.
273
+
274
+ "Deepest" is measured by the length of the origin's RESOLVED path
275
+ (`resolve_origin`), NOT the raw registry key string. That distinction is
276
+ load-bearing, found by review (#904): `covers` (`sdk_discovery._workspace_under`
277
+ in production) decides containment on `.resolve()`d paths, so a symlinked
278
+ origin makes the raw key's string length disagree with `covers`'s own
279
+ notion of "deeper" -- two origins reached through a symlink can be
280
+ identical, or even INVERTED in order, once resolved, while differing in
281
+ raw length. (A path-segment COUNT of the raw key does not fix this
282
+ either: a symlink can collapse or expand the segment count exactly as it
283
+ can the character count.) Ranking by the SAME resolved value `covers`
284
+ uses restores the exactness this function claims: every candidate this
285
+ loop ranks has already passed `covers(workspace_root, origin)`, so any
286
+ two of them are both (resolved) ancestors of the SAME resolved
287
+ `workspace_root` -- one ancestor of two ancestors of one point is always
288
+ an ancestor of the other (or they are the same directory), which makes
289
+ "contains the other's RESOLVED string as a prefix" and "is the shorter
290
+ RESOLVED string" the identical fact for this already-filtered set.
291
+
292
+ `resolve_origin` is injected the same way `covers` and `has_loader_script`
293
+ are, for the same reason: this module stays free of any direct filesystem
294
+ touch, even one as innocuous-looking as `Path.resolve()`.
295
+
296
+ A covering entry whose `sdkPath` fails `has_loader_script` is SKIPPED,
297
+ not merely deprioritised: the next-deepest covering entry (if any) is
298
+ tried instead, the same way the single legacy pointer degrades to a lower
299
+ tier today when ITS target fails the identical check -- a stale entry
300
+ left behind by a checkout that moved or was deleted must not block a
301
+ shallower, still-valid one from answering.
302
+
303
+ **A resolved-depth TIE is broken by `updated_at` recency, most-recent
304
+ wins** (review, #904 second round, major). Two DISTINCT raw origins can
305
+ resolve to the exact SAME directory -- a symlinked alias bootstrapped
306
+ once, then the same directory bootstrapped AGAIN later through its real
307
+ path -- and then tie on `depth` exactly. The pre-fix `depth > best_depth`
308
+ strict inequality never replaces a tie, so whichever origin
309
+ `registry.items()` visits FIRST keeps the win; since the file is written
310
+ with `sort_keys=True` (`registry_text`), that is the lexicographically
311
+ SMALLEST raw origin string, an accident of spelling with no relationship
312
+ to which bootstrap is authoritative. `updated_at` (origin -> its entry's
313
+ `updatedAt`, `sdk_default_registry.parse_registry_updated_at`) is
314
+ threaded through the same way `resolve_origin` is -- plain data, not a
315
+ filesystem touch -- and an origin missing a timestamp (a registry
316
+ written before this fix, or hand-edited) degrades to `""`, which never
317
+ outranks a real stamp and never breaks an existing tie either, so it is
318
+ exactly as decided as it always was. The comparison itself runs on
319
+ `_comparable_updated_at`'s output, not the raw string (review, #904
320
+ final round, nit): a raw string compare ranks a bare seconds-precision
321
+ stamp AHEAD of a same-second milliseconds one (`'Z'` > `'.'`), which
322
+ only a hand-edited entry can produce today, but this module already
323
+ anticipates hand-edited registries, so the tie-break normalises
324
+ precision rather than leaving that case undefined.
325
+
326
+ **Why recency, not collapsing the registry KEY onto its resolved origin
327
+ at write time (the other fix considered, and rejected, in review #904
328
+ second round):** resolving at WRITE time and storing that resolved value
329
+ AS the key would reintroduce, for the key, precisely the staleness this
330
+ function's own `resolve_origin` injection exists to avoid for the
331
+ RANKING -- a value baked in once at write time that the live filesystem
332
+ can silently outgrow (a symlink repointed after the fact, without a new
333
+ `tan bootstrap` ever touching that origin again), with no
334
+ `has_loader_script`-style fallback able to catch it, because the
335
+ original raw origin -- the only thing a future write could still
336
+ recognise -- would already be gone, overwritten by whatever it resolved
337
+ to at the moment it was written. Recency needs none of that: `origin`
338
+ keeps meaning exactly what `tan bootstrap` was run in for every entry,
339
+ forever, matching what `written_for`/`global_default_pointer_fix_hint`
340
+ already report elsewhere in this system, and the tie-break is decided by
341
+ a plain, append-only timestamp instead of a second filesystem-truth
342
+ baked into the registry's own keys.
343
+ """
344
+ updated_at = updated_at or {}
345
+ best: tuple[str, str] | None = None
346
+ best_depth = -1
347
+ best_updated_at = ""
348
+ for origin, sdk_path in registry.items():
349
+ if not is_absolute_either_platform(origin):
350
+ continue
351
+ if not covers(workspace_root, origin):
352
+ continue
353
+ if not has_loader_script(Path(sdk_path)):
354
+ continue
355
+ depth = len(resolve_origin(origin).replace("\\", "/"))
356
+ entry_updated_at = _comparable_updated_at(updated_at.get(origin, ""))
357
+ if depth > best_depth or (depth == best_depth and entry_updated_at > best_updated_at):
358
+ best_depth = depth
359
+ best_updated_at = entry_updated_at
360
+ best = (origin, sdk_path)
361
+ return best
362
+
363
+
364
+ def with_entry(
365
+ registry: dict[str, Any], *, origin: str, sdk_root: str, updated_at: str
366
+ ) -> dict[str, str]:
367
+ """`registry`, a raw parsed-JSON dict (may hold entries in any shape --
368
+ this is the WRITE side, applied to whatever `parse_registry`-adjacent
369
+ reading a writer already did), with `origin` keyed to `{"sdkPath":
370
+ sdk_root, "updatedAt": updated_at}`. Every other key is left untouched,
371
+ so one `tan bootstrap` run updating its own origin never drops another
372
+ project's entry.
373
+
374
+ **`updated_at` is the caller's to compute, not this function's** (review,
375
+ #904 second round, major -- `deepest_covering_entry`'s recency
376
+ tie-break): this module stays free of the wall clock the same way it
377
+ stays free of the filesystem, so every OTHER function here is a plain,
378
+ deterministic function of its arguments and stays that way, testable
379
+ without freezing time. The production caller
380
+ (`bootstrap_cmd._write_global_sdk_registry`) passes
381
+ `tan.core.timestamp.wall_clock_iso(millis=True)` -- **not**
382
+ `generated_at_iso`, and that distinction is load-bearing (review, #904
383
+ third round, major): `generated_at_iso` lets `SOURCE_DATE_EPOCH` win over
384
+ the clock, which is exactly right for a reproducible envelope and exactly
385
+ wrong here, because this field's entire job is to ORDER two writes
386
+ against each other on one host -- a value every bootstrap inside one
387
+ `SOURCE_DATE_EPOCH`-pinned shell would render identically, silently
388
+ reopening the very tie this tie-break exists to close. Millisecond
389
+ precision, deliberately finer than the legacy pointer's own `updatedAt`
390
+ (seconds, `_global_sdk_pointer_json`), because a whole SDK relocation
391
+ plus this write easily completing inside one second is not a corner case
392
+ worth losing the tie-break to.
393
+
394
+ **This function alone is still append-only** (review, #904, nit 1): it
395
+ only ever adds or overwrites `origin`, never drops another entry, even a
396
+ dead one. Pruning is a SEPARATE function, `prune_dead_origins` below
397
+ (tan-cli#905) -- the production writer
398
+ (`bootstrap_cmd._write_global_sdk_registry`) runs it right alongside this
399
+ one, on every relocating `tan bootstrap`, so the two together keep the
400
+ registry both current (this function) and bounded (that one) in the same
401
+ read-modify-write. See `prune_dead_origins`'s own docstring for what
402
+ "dead" means and why.
403
+ """
404
+ updated = dict(registry)
405
+ updated[origin] = {"sdkPath": sdk_root, "updatedAt": updated_at}
406
+ return updated
407
+
408
+
409
+ def prune_dead_origins(
410
+ registry: dict[str, Any], *, origin_exists: Callable[[str], bool]
411
+ ) -> dict[str, Any]:
412
+ """`registry` with every entry whose ORIGIN directory no longer exists on
413
+ this host dropped -- `with_entry`'s "append-only; nothing here prunes"
414
+ gap, closed (tan-cli#905).
415
+
416
+ **What "dead" means here, and why this test and no other.** The module
417
+ docstring above already establishes the candidate set is closed: a key
418
+ can only be here because a real `tan bootstrap` ran in that directory.
419
+ `covers` (`sdk_discovery._workspace_under`) decides a match by STRING
420
+ containment on `.resolve()`d paths and does not itself require either
421
+ side to exist -- so the one fact that makes an origin permanently
422
+ unmatchable, not merely unmatched by the CURRENT invocation, is that its
423
+ own directory is gone: no process can set its current working directory
424
+ to a path that does not exist, so no future `workspace_root` can ever
425
+ again be produced that resolves under a deleted origin (barring the
426
+ coincidence of something unrelated being created at that exact path
427
+ later, at which point the next real `tan bootstrap` there overwrites
428
+ this entry anyway, exactly as it would for a brand-new origin).
429
+
430
+ **Explicitly NOT the criterion: a dead `sdkPath`.**
431
+ `deepest_covering_entry` already SKIPS a covering entry whose `sdkPath`
432
+ fails `has_loader_script`, deliberately, because that origin's PROJECT
433
+ may simply be between bootstraps -- a checkout mid-move, or a project
434
+ that has not re-bootstrapped since its SDK relocated. That entry is
435
+ "merely unused right now", not dead: the very next `tan bootstrap` run
436
+ from that origin repairs it for free, and pruning it first would only
437
+ force a needless rediscovery. Origin existence is the only test this
438
+ module can run that tells the two apart without guessing at intent
439
+ (age-based pruning was considered and rejected for the same reason --
440
+ see the issue -- a long-idle but still-real project origin is
441
+ indistinguishable from an abandoned one by timestamp alone, and getting
442
+ that wrong deletes a live project's entry, not a stale one).
443
+
444
+ Not a filesystem touch itself: `origin_exists` is injected the same way
445
+ `covers`/`has_loader_script`/`resolve_origin` are injected into
446
+ `deepest_covering_entry`, so this module stays free of any direct `Path`
447
+ IO. The production caller (`bootstrap_cmd._write_global_sdk_registry`)
448
+ degrades an INCONCLUSIVE check (a `PermissionError`, or any other
449
+ exception) to "exists" -- an entry is dropped only on a confirmed
450
+ absence, never on an inability to check one, the same conservative bias
451
+ every other best-effort read in this module already applies.
452
+
453
+ Applies to every key in `registry`, including one shaped too oddly for
454
+ `parse_registry`/`parse_registry_updated_at` to have kept (a bare string
455
+ value, a non-dict entry): a malformed entry whose origin is gone is
456
+ exactly as dead as a well-formed one, and skipping it would leave the one
457
+ thing this function exists to bound -- the registry's own unbounded
458
+ growth (tan-cli#905's own complaint) -- unfixed for exactly the shapes
459
+ most likely to belong to a hand-edited or long-abandoned entry.
460
+
461
+ No concurrency story of its own: called from the SAME read-modify-write
462
+ `with_entry` already is, so a prune racing a second `tan bootstrap`'s
463
+ write carries the identical last-writer-wins tolerance
464
+ `_write_global_sdk_registry` already accepts for that write -- the
465
+ LOSER's entry (add or prune) is simply overwritten by the winner's next
466
+ write, never corrupted or partially applied.
467
+ """
468
+ return {origin: entry for origin, entry in registry.items() if origin_exists(origin)}
469
+
470
+
471
+ def normalized_sdk_path(value: str) -> str:
472
+ """A registry `sdkPath` reduced to the spelling its WRITER would have used,
473
+ so a stored value and a freshly-computed target compare equal.
474
+
475
+ `bootstrap_cmd._write_global_sdk_registry` posix-normalises every `sdkPath`
476
+ it writes (`sdk_discovery._to_posix`), so on POSIX this is already the
477
+ identity and the comparison was never at risk. On Windows it is not:
478
+ `write_registry`-shaped hand edits -- which this module's own
479
+ `parse_registry` contract explicitly accommodates, and which the registry
480
+ docs describe as a real shape a file can be left in -- spell the same
481
+ directory `C:\\Users\\me\\sdk`, and a raw `==` against the
482
+ forward-slash `C:/Users/me/sdk` this codebase computes everywhere else
483
+ then answers False for two names of ONE directory.
484
+
485
+ That mismatch is not cosmetic where it is used. `tan sdk remove` consults
486
+ it twice: to decide whether an install is a registered project's default
487
+ and must therefore be REFUSED without `--force` (tan-cli#790's first design
488
+ bar -- "silently orphaning any of the three is a worse failure than a
489
+ refusal that names exactly what would break"), and to prune the entry
490
+ afterwards. A False here silently removes an install another project still
491
+ points at, which is the one outcome that whole refusal exists to prevent.
492
+
493
+ Separator folding ONLY -- deliberately not `_abs_posix`, whose `abspath`
494
+ would anchor a relative stored value to the REMOVING process's cwd and
495
+ invent a match that the writer never wrote. This is the exact inverse of
496
+ the write, and nothing more. Case folding is NOT applied either: NTFS is
497
+ case-insensitive in practice, so two entries differing only in case name
498
+ one directory there too, but folding case would make this function wrong on
499
+ the case-SENSITIVE POSIX side. That fix landed where it belongs, as the
500
+ platform-aware comparison this result is FED INTO rather than smuggled in
501
+ here -- `sdk_removal.removal_would_take_out` (tan-cli#1053), which folds
502
+ case only where the platform does and consults the filesystem itself when
503
+ the lexical answer misses.
504
+ """
505
+ return value.replace("\\", "/")
506
+
507
+
508
+ def prune_entries_by_sdk_path(
509
+ registry: dict[str, Any], *, sdk_path: str, matches: Callable[[str, str], bool]
510
+ ) -> dict[str, Any]:
511
+ """`registry` with every entry whose `sdkPath` NAMES `sdk_path` -- per
512
+ `matches`, not a raw `==`; see below -- dropped --
513
+ `tan sdk remove`'s own honesty obligation (tan-cli#790), the sibling of
514
+ [`prune_dead_origins`] pruning on the OTHER axis: that function drops an
515
+ entry whose ORIGIN (the project that registered it) no longer exists;
516
+ this one drops an entry whose TARGET (the SDK checkout it points at) was
517
+ just deleted by this very command.
518
+
519
+ Without this, a removed install stays registered as SOME other project's
520
+ machine-global default: that project's next `sdk current`/`tan build`
521
+ would still answer `sourceTier: "globalDefault"` naming a path that is
522
+ gone, degrading only as far as `check_sdk_readiness`'s `state:
523
+ "missing"` -- correctly REPORTED, never repaired, and never falling
524
+ through to a lower tier the way a dead `~/.alp/sdk-default` pointer
525
+ already does. `deepest_covering_entry` applies the identical
526
+ `has_loader_script` gate to a registry hit too, so leaving a dead entry
527
+ in place would not silently mis-resolve anyone -- it would just leave a
528
+ permanently-unusable entry in a file tan-cli#905 exists to keep bounded.
529
+ This closes the gap anyway, on tan-cli#905's own reasoning for pruning
530
+ eagerly: the registry is honest about what still resolves, not merely
531
+ eventually self-correcting about it.
532
+
533
+ `sdk_path` is the caller's to normalise (`sdk_discovery._abs_posix`,
534
+ posix-normalised the same way [`with_entry`]'s own `sdk_root` argument
535
+ already is) -- this module has no opinion on path normalisation, the same
536
+ division every other function here draws.
537
+
538
+ `matches` is the comparison, INJECTED rather than the raw `==` this was
539
+ (tan-cli#1053 review: the fifth site of that defect -- an alias-spelled
540
+ entry was correctly refused and then NOT pruned). Injected rather than
541
+ imported for the reason `deepest_covering_entry` takes `covers`/
542
+ `has_loader_script` that way: the real comparison touches the filesystem
543
+ and lives in a module that already imports THIS one. Required, not
544
+ defaulted -- a default would hand a caller back the defect.
545
+
546
+ Compares only entries whose `sdkPath` is present and a string; anything
547
+ shaped too oddly for `parse_registry` to have kept either cannot name
548
+ `sdk_path` at all or is as findable by [`prune_dead_origins`]'s broader
549
+ sweep -- this narrows on purpose, to the one axis its caller knows changed.
550
+ """
551
+ result: dict[str, Any] = {}
552
+ for origin, entry in registry.items():
553
+ path = entry.get("sdkPath") if isinstance(entry, dict) else None
554
+ if isinstance(path, str) and matches(normalized_sdk_path(path), sdk_path):
555
+ continue
556
+ result[origin] = entry
557
+ return result
558
+
559
+
560
+ def registry_text(registry: dict[str, Any]) -> str:
561
+ """The committed-to-disk rendering: sorted keys (a deterministic diff
562
+ across runs, and a stable read order for a human `cat`ing the file), two-
563
+ space indent matching every other pointer file this repo writes, a
564
+ trailing newline."""
565
+ return json.dumps(registry, indent=2, sort_keys=True) + "\n"