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,296 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Is alp-sdk's `zephyr/patches.yml` applied in the resolved west workspace?
3
+ (tan-cli#1376)
4
+
5
+ A customer hit `alp_camera_open FAILED: ALP_ERR_NOSUPPORT` because the
6
+ workspace lacked patch 0001 (Alif clock `set_rate`) while `tan build` said
7
+ `ok: true`. `tan bootstrap` applies the patches (`bootstrap_patches.py`); this
8
+ module only ASKS, for `tan doctor` and `tan build`, and never writes to the
9
+ workspace.
10
+
11
+ The SDK's own `scripts/verify_west_patches.py` is the source of truth and is
12
+ run unchanged; this module reads its exit code (0 applied / 1 not applied /
13
+ 2 uninspectable / 3 module not checked out) and its failure report. It never
14
+ raises: an absent verifier (older SDK), a missing interpreter, a timeout or an
15
+ unreadable report all become `unchecked`, because a check that cannot run must
16
+ not fail an offline doctor or a build.
17
+
18
+ Caching (`tan build` only): a positive verdict (applied, or applied with some
19
+ modules not checked out) is stored in `<project>/build/` keyed by (every
20
+ workspace module's HEAD, `patches.yml` bytes, and a working-tree fingerprint:
21
+ `(path, mtime_ns, size)` of every file the patches write, looked up under every
22
+ module checkout). The fingerprint matters because `west patch clean`,
23
+ `git checkout -- .` and `git stash` revert patches WITHOUT moving HEAD; they
24
+ rewrite the patched files, so mtime/size change and the cache misses. Only
25
+ positive verdicts are cached: a cached "missing" would go stale the moment the
26
+ user ran `tan bootstrap`.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import re
32
+ import subprocess
33
+ import tempfile
34
+ from dataclasses import dataclass, field, replace
35
+ from pathlib import Path
36
+
37
+ from tan.core.atomic_write import atomic_write_text
38
+ from tan.core.host_python import probe_host_python
39
+ from tan.core.patch_paths import patch_paths
40
+ from tan.core.subprocess_env import spawn_env
41
+ from tan.core.venv import venv_python, west_program
42
+ from tan.core.west_patches import (
43
+ UnappliedPatch,
44
+ cache_key,
45
+ classify_verify,
46
+ parse_unapplied,
47
+ parse_unapplied_patches,
48
+ )
49
+
50
+ APPLIED = "applied"
51
+ MISSING = "missing"
52
+ UNCHECKED = "unchecked"
53
+
54
+ CACHE_FILE = ".tan-workspace-patches.json"
55
+ _VERIFIER = Path("scripts") / "verify_west_patches.py"
56
+ _PATCHES_YML = Path("zephyr") / "patches.yml"
57
+ _TIMEOUT_S = 120
58
+ _PYTHON_FLOOR = (3, 10)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class PatchCheck:
63
+ state: str # APPLIED | MISSING | UNCHECKED
64
+ patches: list[UnappliedPatch] = field(default_factory=list)
65
+ modules: list[str] = field(default_factory=list)
66
+ #: Why `unchecked`, or the applied count line; empty for `missing`.
67
+ note: str = ""
68
+ cached: bool = False
69
+ #: Why `tan build` re-verifies on every build: the cache key could not be
70
+ #: built in full, so nothing is cached. Empty when caching was in play.
71
+ cache_note: str = ""
72
+
73
+
74
+ def _run(argv: list[str], cwd: str, timeout: int = _TIMEOUT_S) -> tuple[int | None, str, str]:
75
+ try:
76
+ out = subprocess.run(
77
+ argv, cwd=cwd, capture_output=True, text=True, encoding="utf-8",
78
+ errors="replace", stdin=subprocess.DEVNULL, timeout=timeout,
79
+ env=spawn_env(), check=False,
80
+ )
81
+ except (OSError, ValueError, subprocess.SubprocessError):
82
+ return None, "", ""
83
+ return out.returncode, out.stdout, out.stderr
84
+
85
+
86
+ def _head(module_dir: Path) -> str:
87
+ """A cheap, spawn-free identity of a checkout's HEAD. Not a verdict: it
88
+ only has to CHANGE when the checkout moves."""
89
+ try:
90
+ git = module_dir / ".git"
91
+ gitdir = git
92
+ if git.is_file():
93
+ text = git.read_text(encoding="utf-8").strip()
94
+ if text.startswith("gitdir:"):
95
+ gitdir = (module_dir / text[len("gitdir:"):].strip()).resolve()
96
+ head_file = gitdir / "HEAD"
97
+ head = head_file.read_text(encoding="utf-8").strip()
98
+ if head.startswith("ref:"):
99
+ ref = head[4:].strip()
100
+ loose = gitdir / ref
101
+ if loose.is_file():
102
+ return loose.read_text(encoding="utf-8").strip()
103
+ packed = gitdir / "packed-refs"
104
+ if packed.is_file():
105
+ for line in packed.read_text(encoding="utf-8").splitlines():
106
+ if line.endswith(" " + ref):
107
+ return line.split(" ", 1)[0]
108
+ return f"{ref}@{head_file.stat().st_mtime_ns}"
109
+ return head
110
+ except (OSError, UnicodeDecodeError):
111
+ return "unreadable"
112
+
113
+
114
+ def _workspace_heads(west: str, workspace: Path) -> dict[str, str] | None:
115
+ code, out, _ = _run([west, "list", "-f", "{abspath}"], str(workspace))
116
+ if code != 0:
117
+ return None
118
+ return {p: _head(Path(p)) for p in (ln.strip() for ln in out.splitlines()) if p}
119
+
120
+
121
+ _PATCH_ENTRY = re.compile(r"^\s*-?\s*path:\s*['\"]?([^'\"#\s]+)", re.MULTILINE)
122
+
123
+
124
+ def _patched_files(sdk: Path) -> list[str] | None:
125
+ """Repo-relative paths every `patches.yml` patch touches, or `None`.
126
+
127
+ Every path a patch touches counts, deletions, renames and binary patches
128
+ included (`tan.core.patch_paths`), so a revert that restores a deleted
129
+ file moves the fingerprint.
130
+
131
+ `None` when the list cannot be trusted to be complete: `patches.yml` or
132
+ any named patch is unreadable, or no path was found at all. A partial
133
+ list would silently weaken the cache key towards HEAD-only, reopening the
134
+ revert hole for the dropped files, so the caller skips caching instead.
135
+ """
136
+ rels: set[str] = set()
137
+ try:
138
+ entries = _PATCH_ENTRY.findall((sdk / _PATCHES_YML).read_text(encoding="utf-8"))
139
+ for entry in entries:
140
+ text = (sdk / "zephyr" / "patches" / entry).read_text(encoding="utf-8", errors="replace")
141
+ rels.update(patch_paths(text))
142
+ except (OSError, ValueError):
143
+ return None
144
+ return sorted(rels) or None
145
+
146
+
147
+ def _tree_fingerprint(module_dirs: list[str], rels: list[str]) -> dict[str, str]:
148
+ """`{path: "mtime_ns:size"}` for each patched file present under any module."""
149
+ out: dict[str, str] = {}
150
+ for d in module_dirs:
151
+ for rel in rels:
152
+ f = Path(d) / rel
153
+ try:
154
+ st = f.stat()
155
+ except OSError:
156
+ continue
157
+ out[str(f)] = f"{st.st_mtime_ns}:{st.st_size}"
158
+ return out
159
+
160
+
161
+ def _cache_key(west: str, workspace: Path, sdk: Path, why: list[str]) -> str | None:
162
+ """The cache key, or `None` after appending the reason to `why` when it
163
+ cannot be built in full (nothing is then cached)."""
164
+ heads = _workspace_heads(west, workspace)
165
+ if heads is None:
166
+ why.append("the west workspace modules could not be listed")
167
+ return None
168
+ try:
169
+ rels = _patched_files(sdk)
170
+ if rels is None:
171
+ why.append("zephyr/patches.yml or a patch it names could not be read in full")
172
+ return None
173
+ tree = _tree_fingerprint(list(heads), rels)
174
+ return cache_key((sdk / _PATCHES_YML).read_bytes(), {**heads, **tree})
175
+ except OSError:
176
+ why.append("zephyr/patches.yml could not be read")
177
+ return None
178
+
179
+
180
+ def _interpreter(workspace: Path, sdk_root: str) -> str | None:
181
+ # The workspace venv carries west + pyyaml (Zephyr's requirements), which
182
+ # the verifier imports; the resolved host Python is the fallback.
183
+ py = venv_python(str(workspace), sdk_root)
184
+ if py is not None:
185
+ return py
186
+ host = probe_host_python(_PYTHON_FLOOR, need_west=True)
187
+ return host.interpreter if host is not None else None
188
+
189
+
190
+ def _read_cache(path: Path, key: str) -> str | None:
191
+ """The cached state (`APPLIED` or `UNCHECKED`) for `key`, else `None`."""
192
+ try:
193
+ doc = json.loads(path.read_text(encoding="utf-8"))
194
+ except (OSError, ValueError):
195
+ return None
196
+ if isinstance(doc, dict) and doc.get("key") == key and doc.get("state") in (APPLIED, UNCHECKED):
197
+ return doc["state"]
198
+ return None
199
+
200
+
201
+ def _cache_hit(cache_dir: Path, key: str | None) -> PatchCheck | None:
202
+ cached = _read_cache(cache_dir / CACHE_FILE, key) if key is not None else None
203
+ if cached == APPLIED:
204
+ return PatchCheck(APPLIED, note="verified applied (cached)", cached=True)
205
+ if cached == UNCHECKED:
206
+ return PatchCheck(UNCHECKED, note=_PARTIAL_NOTE, cached=True)
207
+ return None
208
+
209
+
210
+ def _write_cache(path: Path, key: str, state: str) -> None:
211
+ try:
212
+ path.parent.mkdir(parents=True, exist_ok=True)
213
+ atomic_write_text(str(path), json.dumps({"key": key, "state": state}))
214
+ except Exception: # noqa: BLE001 -- the cache is an optimisation
215
+ pass
216
+
217
+
218
+ _PARTIAL_NOTE = (
219
+ "some zephyr/patches.yml modules are not checked out in this workspace; "
220
+ "their patches could not be checked"
221
+ )
222
+
223
+
224
+ def check_workspace_patches(
225
+ workspace: Path,
226
+ sdk_root: str | None,
227
+ *,
228
+ cache_dir: Path | None = None,
229
+ timeout: int = _TIMEOUT_S,
230
+ ) -> PatchCheck:
231
+ """Verify `<sdk_root>/zephyr/patches.yml` against `workspace`. Read-only,
232
+ and never raises: anything unexpected is `unchecked`."""
233
+ why: list[str] = []
234
+ try:
235
+ result = _check(workspace, sdk_root, cache_dir, timeout, why)
236
+ if why and result.state != MISSING:
237
+ result = replace(result, cache_note=why[0])
238
+ return result
239
+ except Exception: # noqa: BLE001 -- an advisory check must never break its caller
240
+ return PatchCheck(UNCHECKED, note="the patch check failed unexpectedly")
241
+
242
+
243
+ def _check(
244
+ workspace: Path, sdk_root: str | None, cache_dir: Path | None, timeout: int,
245
+ why: list[str],
246
+ ) -> PatchCheck:
247
+ if sdk_root is None:
248
+ return PatchCheck(UNCHECKED, note="no alp-sdk checkout resolved")
249
+ sdk = Path(sdk_root)
250
+ verifier, patches_yml = sdk / _VERIFIER, sdk / _PATCHES_YML
251
+ if not verifier.is_file() or not patches_yml.is_file():
252
+ return PatchCheck(
253
+ UNCHECKED,
254
+ note="this alp-sdk checkout carries no patch verifier "
255
+ "(scripts/verify_west_patches.py), so patches cannot be checked",
256
+ )
257
+ west = west_program(str(workspace), sdk_root)
258
+ key = None
259
+ if cache_dir is not None:
260
+ key = _cache_key(west, workspace, sdk, why)
261
+ hit = _cache_hit(cache_dir, key)
262
+ if hit is not None:
263
+ return hit
264
+
265
+ python = _interpreter(workspace, sdk_root)
266
+ if python is None:
267
+ return PatchCheck(UNCHECKED, note="no Python able to run the patch verifier was found")
268
+ argv = [
269
+ python, str(verifier), "--repo", str(sdk),
270
+ "--topdir", str(workspace), "--west", west,
271
+ ]
272
+ with tempfile.TemporaryDirectory(prefix="tan-patches-") as scratch:
273
+ code, _out, err = _run(argv, scratch, timeout)
274
+ verdict = classify_verify(code)
275
+ if verdict == "applied":
276
+ if key is not None and cache_dir is not None:
277
+ _write_cache(cache_dir / CACHE_FILE, key, APPLIED)
278
+ return PatchCheck(APPLIED, note="verified applied")
279
+ if verdict == "unchecked":
280
+ # Everything inspectable is patched: as good as applied until a
281
+ # head or patched file moves, so it is cached too.
282
+ if key is not None and cache_dir is not None:
283
+ _write_cache(cache_dir / CACHE_FILE, key, UNCHECKED)
284
+ return PatchCheck(UNCHECKED, note=_PARTIAL_NOTE)
285
+ if verdict != "unapplied" or "Traceback" in err:
286
+ return PatchCheck(
287
+ UNCHECKED,
288
+ note="the patch verifier could not inspect this workspace "
289
+ "(run scripts/verify_west_patches.py directly to see why)",
290
+ )
291
+ # Exit 1 is the verifier's "not applied"; if it printed no parsable
292
+ # line, the patches are unnamed but still missing.
293
+ patches = parse_unapplied_patches(err)
294
+ lcode, listing, _ = _run([*argv, "--list-unapplied"], scratch, timeout)
295
+ modules = parse_unapplied(listing) if lcode == 0 else []
296
+ return PatchCheck(MISSING, patches=patches, modules=modules)
tan/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,100 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """What to tell a `tan init --from-example` user whose copied example reads a
3
+ pre-generated `generated/alp.conf` (alp-sdk#866).
4
+
5
+ alp-sdk#866 deleted the example `CMakeLists.txt` bridge that wrote the
6
+ per-core `alp.conf` at configure time. An example's twister scenarios and
7
+ bare `west build` lines now pass `EXTRA_CONF_FILE=generated/alp.conf`, and
8
+ its prose points at alp-sdk's `scripts/gen_example_alp_conf.py` to write it.
9
+ That script only walks alp-sdk's own `examples/`: run against a project
10
+ `tan init --from-example` copied out, it exits 0 having written nothing.
11
+ `tan build` needs none of it (the plan writes and passes its own `alp.conf`),
12
+ but a bare `west build`/twister run in the copied project names a missing
13
+ overlay, which Zephyr refuses.
14
+
15
+ `--from-example` copies the example verbatim -- the prose is the SDK's, at
16
+ the bound SDK's revision, and tan does not rewrite it. What tan CAN do
17
+ honestly is say, once, which command writes the file in this project:
18
+ `tan generate --target zephyr-conf --core <id> --output <dir>/generated/alp.conf`
19
+ writes exactly the bytes the build plan's `configArtefacts` carry for that
20
+ core. Pure: takes the planned files, returns the message or `None`.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import posixpath
26
+ import re
27
+ from collections.abc import Iterable
28
+ from typing import Protocol
29
+
30
+ #: The reference the message is about. `(?<![\w])` rejects Zephyr's
31
+ #: per-image `<image>_EXTRA_CONF_FILE=` spelling.
32
+ _READS_GENERATED = re.compile(r"(?<![\w])EXTRA_CONF_FILE=generated/alp\.conf(?![\w./])")
33
+
34
+ #: Core OSes that have no Zephyr `alp.conf` at all.
35
+ _NON_ZEPHYR_OS = frozenset({"off", "yocto", "baremetal", "linux"})
36
+
37
+ _CORE_PLACEHOLDER = "<core-id>"
38
+
39
+
40
+ class _File(Protocol):
41
+ relative_path: str
42
+ content: str
43
+
44
+
45
+ def _dir_of(path: str) -> str:
46
+ return posixpath.dirname(path)
47
+
48
+
49
+ def _app_dirs(cores: object, paths: set[str]) -> dict[str, list[str]]:
50
+ """`{project-relative west app dir: [core ids]}` for every Zephyr-capable
51
+ core. Mirrors the planner's `_zephyr_app_dir` rule: the `app:` directory
52
+ itself when it carries a `CMakeLists.txt`, else its parent."""
53
+ out: dict[str, list[str]] = {}
54
+ if not isinstance(cores, dict):
55
+ return out
56
+ for core_id, cfg in cores.items():
57
+ if not isinstance(core_id, str) or not isinstance(cfg, dict):
58
+ continue
59
+ if str(cfg.get("os", "")).lower() in _NON_ZEPHYR_OS:
60
+ continue
61
+ app = cfg.get("app")
62
+ if not isinstance(app, str) or not app.strip():
63
+ continue
64
+ app_dir = posixpath.normpath(app.strip())
65
+ app_dir = "" if app_dir == "." else app_dir
66
+ if posixpath.join(app_dir, "CMakeLists.txt") not in paths:
67
+ app_dir = _dir_of(app_dir)
68
+ out.setdefault(app_dir, []).append(core_id)
69
+ return out
70
+
71
+
72
+ def pregeneration_message(
73
+ files: Iterable[_File], board_cores: object, sdk_display: str, subject: str
74
+ ) -> str | None:
75
+ """The one-paragraph instruction, or `None` when no copied file reads
76
+ `generated/alp.conf`. *board_cores* is the copied board.yaml's parsed
77
+ `cores:` mapping (anything else degrades to the `<core-id>` placeholder).
78
+ """
79
+ files = list(files)
80
+ readers = sorted(f.relative_path for f in files if _READS_GENERATED.search(f.content))
81
+ if not readers:
82
+ return None
83
+ by_dir = _app_dirs(board_cores, {f.relative_path for f in files})
84
+ commands = []
85
+ for directory in sorted({_dir_of(r) for r in readers}):
86
+ candidates = by_dir.get(directory, [])
87
+ core = candidates[0] if len(candidates) == 1 else _CORE_PLACEHOLDER
88
+ output = posixpath.join(directory, "generated/alp.conf")
89
+ commands.append(
90
+ f"tan generate --target zephyr-conf --core {core} "
91
+ f"--sdk-root {sdk_display} --output {output}"
92
+ )
93
+ return (
94
+ f"{subject} reads generated/alp.conf ({', '.join(readers)}): `tan build` "
95
+ "writes its own and does not need it, but a bare `west build` or twister "
96
+ "run in this project does. Write it with: "
97
+ + "; ".join(f"`{c}`" for c in commands)
98
+ + ". The example's own pointer, alp-sdk's scripts/gen_example_alp_conf.py, "
99
+ "only writes inside alp-sdk's examples/ (alp-sdk#866)."
100
+ )
@@ -0,0 +1,240 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`metadata/bootstrap.json`'s `artifactProvenance` block, normalised --
3
+ pure, no IO (same contract as `tan.core.bootstrap`, which imports this).
4
+
5
+ alp-sdk v0.16.0 (alplabai/alp-sdk#1574) added a top-level `artifactProvenance`
6
+ map to the manifest tan already reads for `missingPrerequisites[].command`:
7
+
8
+ "artifactProvenance": {
9
+ "cmake": {"tier": "A", "source": "https://cmake.org/",
10
+ "sizeBytes": null, "licence": "BSD-3-Clause"},
11
+ ...
12
+ }
13
+
14
+ Its keys are the `prerequisites.{posix,macos,windows}` tool vocabulary (plus
15
+ `7zip`) -- the SAME names `missingPrerequisites[].tool` already carries -- so
16
+ carrying the block onto those entries is a join on an identity that already
17
+ exists, not a new one (tan-cli#1066).
18
+
19
+ **`source` on the wire becomes `sourceUrl`.** alp-sdk spells the upstream
20
+ project page `source`; the envelope deliberately does not. The consumer that
21
+ asked for this (alp-sdk-vscode#467's dependency-consent screen) already
22
+ defines a `source` column as *what will actually run* -- the install command,
23
+ verbatim -- and the artifact a user actually installs is a brew bottle or an
24
+ apt package, not the tarball behind that page. One name for two meanings would
25
+ misinform the security reader the screen exists for, so tan-cli#1066 renames
26
+ it at the seam it crosses. `licence` keeps alp-sdk's own (British) spelling,
27
+ because that one is the same fact under the same name.
28
+
29
+ **Absence is `null`, never an omitted key and never a default.** `xz` and
30
+ `7zip` genuinely declare no licence upstream and every entry's `sizeBytes` is
31
+ `null` today; an SDK predating the block (v0.16.0-rc1 and earlier) reports
32
+ `null` for all four. "Not reported" is a true answer and an invented one is
33
+ not -- and a key set that is stable regardless of the SDK in front of it is
34
+ what lets `contract/doctor-data-keys.json` declare one shape rather than
35
+ "these four keys, sometimes".
36
+
37
+ **Malformed input DEGRADES, it does not refuse.** This is the one place this
38
+ module deliberately does NOT reuse the curated-error register the sibling
39
+ readers use (`planner.template._require_mapping_doc` / `_require_field`, PR
40
+ tan-cli#1073/#1082, which raise a named `TemplateError`). Those guard a
41
+ document whose content the command cannot proceed without. This one is
42
+ advisory display metadata hanging off `tan doctor` -- whose whole job is to
43
+ report on a broken environment, and which already treats a missing or
44
+ malformed `bootstrap.json` as a warning with a documented fallback rather than
45
+ a refusal (`doctor_cmd._load_manifest`'s own docstring: "a doctor that cannot
46
+ start because the thing it diagnoses is broken is the failure mode it exists
47
+ to prevent"). Refusing here would let one upstream typo in a field nobody
48
+ builds with take out the diagnosis of every OTHER thing wrong with the host.
49
+ So every malformed shape -- a non-mapping block, a non-mapping entry, a
50
+ missing or wrongly-typed field -- collapses to the SAME `null` an absent SDK
51
+ key yields, per FIELD rather than per block: a `cmake` entry whose `sizeBytes`
52
+ is the string `"12"` still reports its real `tier`/`licence`/`sourceUrl`.
53
+
54
+ **Degrading is not the same as saying nothing** (tan-cli#1066 review).
55
+ Silently, a corrupt block and an SDK predating the block produce byte-identical
56
+ envelopes -- so an alp-sdk generator regression would render "not reported" on
57
+ every consent screen with `tan doctor` saying nothing at all, which is the
58
+ failure mode `doctor` exists to prevent. `problems_in` (below) is what makes it
59
+ sayable: `doctor_cmd` raises its EXISTING `bootstrapManifest` warn and
60
+ `doctor.bootstrap-manifest` issue for a block that is present and unreadable,
61
+ and stays silent for one that is merely absent. Nothing about the entries or
62
+ the exit code changes -- the four keys are still there, still `null`.
63
+
64
+ Its report is DERIVED from these parsers, never a second opinion about types: a
65
+ field is called unreadable exactly when the manifest declared a value (not
66
+ `null`, not absent) and the real reader still answered `None`. Tightening
67
+ `_count` -- the negative-size refusal, say -- starts being reported with no
68
+ change to `problems_in` at all; loosening it stops being reported. A test pins
69
+ that coupling rather than trusting this paragraph.
70
+ """
71
+ from __future__ import annotations
72
+
73
+ from dataclasses import dataclass
74
+ from typing import Any
75
+
76
+ #: The manifest's own key for the block, and for the one field renamed on the
77
+ #: wire. Named rather than inlined so the rename has exactly one spelling.
78
+ BLOCK_KEY = "artifactProvenance"
79
+ SDK_SOURCE_KEY = "source"
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class ArtifactProvenance:
84
+ """One tool's provenance, already normalised to the wire's own shape.
85
+
86
+ Every field defaults to `None` so `ArtifactProvenance()` IS the "not
87
+ reported" value -- there is no second spelling of absence to keep in sync,
88
+ and a caller with no table at hand (`UNKNOWN` below) produces exactly what
89
+ a caller holding a table with no entry for its tool does.
90
+ """
91
+
92
+ tier: str | None = None
93
+ licence: str | None = None
94
+ source_url: str | None = None
95
+ size_bytes: int | None = None
96
+
97
+ def as_dict(self) -> dict[str, str | int | None]:
98
+ """The four keys as `missingPrerequisites[]` carries them. Always all
99
+ four, always in this order -- see the module docstring on absence."""
100
+ return {
101
+ "tier": self.tier,
102
+ "licence": self.licence,
103
+ "sourceUrl": self.source_url,
104
+ "sizeBytes": self.size_bytes,
105
+ }
106
+
107
+
108
+ #: The "nothing reported" provenance: an SDK predating `artifactProvenance`, a
109
+ #: tool with no entry in it (`west`, `zephyrSdk`, `setools`, `jlink` and
110
+ #: `python3-venv` all have none today), or an entry too malformed to read.
111
+ UNKNOWN = ArtifactProvenance()
112
+
113
+
114
+ def _text(value: Any) -> str | None:
115
+ """A string field, or `None` for anything else -- including a number, a
116
+ `null` alp-sdk wrote deliberately (`xz`/`7zip`'s `licence`), and a nested
117
+ object. No `str()` coercion: rendering `{'a': 1}` as a licence in a consent
118
+ screen would be an invented fact, which is the one thing this must not do."""
119
+ return value if isinstance(value, str) else None
120
+
121
+
122
+ def _count(value: Any) -> int | None:
123
+ """A byte count, or `None`. `bool` is excluded explicitly for the reason
124
+ the sibling readers exclude it (`parse_bootstrap_manifest`, `_load_manifest`):
125
+ `True == 1` in Python, so `"sizeBytes": true` would otherwise report a size
126
+ of one byte. A float is rejected rather than truncated -- alp-sdk emits
127
+ `null` or an integer, and a truncation here would be tan inventing a
128
+ precision the manifest never claimed.
129
+
130
+ A NEGATIVE int is refused for the same reason as the float: `-4096` is not
131
+ a download size, and rendering "-4 KB" on a consent screen is the same
132
+ class of nonsense as rendering a truncated float. `0` is kept -- an empty
133
+ artefact is silly but arithmetically meaningful, and `null` already exists
134
+ for "not reported", so coercing `0` would erase a distinction the manifest
135
+ can actually draw."""
136
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
137
+ return None
138
+ return value
139
+
140
+
141
+ def entry_from_doc(doc: Any) -> ArtifactProvenance:
142
+ """One `artifactProvenance[<tool>]` value, normalised. Anything that is not
143
+ a mapping (a bare string, a list, `null`) yields `UNKNOWN` rather than
144
+ raising -- see the module docstring."""
145
+ if not isinstance(doc, dict):
146
+ return UNKNOWN
147
+ return ArtifactProvenance(
148
+ tier=_text(doc.get("tier")),
149
+ licence=_text(doc.get("licence")),
150
+ source_url=_text(doc.get(SDK_SOURCE_KEY)),
151
+ size_bytes=_count(doc.get("sizeBytes")),
152
+ )
153
+
154
+
155
+ def parse_table(raw: Any) -> dict[str, ArtifactProvenance]:
156
+ """The whole `artifactProvenance` block, normalised to `tool ->
157
+ ArtifactProvenance`.
158
+
159
+ `{}` for an absent block, a block that is not a mapping, or one whose every
160
+ entry is unreadable -- all four of which are the same fact downstream
161
+ ("nothing to report"), and none of which is an error here. A non-string key
162
+ is dropped: `missingPrerequisites[].tool` is a string, so a key that could
163
+ never match one carries no information and keeping it would only widen this
164
+ return type for a lookup that cannot hit.
165
+ """
166
+ if not isinstance(raw, dict):
167
+ return {}
168
+ return {
169
+ tool: entry_from_doc(doc) for tool, doc in raw.items() if isinstance(tool, str)
170
+ }
171
+
172
+
173
+ def _entry_problems(tool: str, entry: Any) -> list[str]:
174
+ """One tool's row: everything the manifest DECLARED there that the real
175
+ readers could not read, named `<tool>.<manifest key>`.
176
+
177
+ Derived from `entry_from_doc`'s own answer rather than a second copy of the
178
+ type rules, so it cannot drift from what the parse actually accepts (see
179
+ the module docstring's "Malformed input" section)."""
180
+ if not isinstance(entry, dict):
181
+ kind = "null" if entry is None else type(entry).__name__
182
+ return [f"`{tool}` is {kind}, not an object"]
183
+ parsed = entry_from_doc(entry)
184
+ return [
185
+ f"`{tool}.{key}` is not readable ({entry[key]!r})"
186
+ for key, read in (
187
+ ("tier", parsed.tier),
188
+ ("licence", parsed.licence),
189
+ (SDK_SOURCE_KEY, parsed.source_url),
190
+ ("sizeBytes", parsed.size_bytes),
191
+ )
192
+ # DECLARED and unreadable. An absent key and an explicit `null` are
193
+ # both "not reported", the normal shape (`xz`/`7zip` licence, every
194
+ # `sizeBytes` today), and neither is a problem.
195
+ if entry.get(key) is not None and read is None
196
+ ]
197
+
198
+
199
+ def problems_in(doc: dict[str, Any]) -> str | None:
200
+ """What in `doc`'s `artifactProvenance` block tan could not read, as ONE
201
+ human-readable line, or `None` when there is nothing to say.
202
+
203
+ Takes the whole parsed manifest, not the block, because the distinction it
204
+ draws is about the KEY: an absent `artifactProvenance` is every SDK before
205
+ v0.16.0 and stays silent, while a key that IS present and unreadable --
206
+ `"artifactProvenance": null` included, which no alp-sdk has ever emitted --
207
+ is a producer defect worth naming. It reports; it never refuses and never
208
+ changes what the entries carry. See the module docstring for why silence
209
+ was not an option and which channel this feeds.
210
+ """
211
+ if BLOCK_KEY not in doc:
212
+ return None
213
+ raw = doc[BLOCK_KEY]
214
+ if not isinstance(raw, dict):
215
+ kind = "null" if raw is None else type(raw).__name__
216
+ return f"`{BLOCK_KEY}` is {kind}, not an object"
217
+
218
+ problems: list[str] = []
219
+ for tool, entry in raw.items():
220
+ if not isinstance(tool, str):
221
+ problems.append(f"`{BLOCK_KEY}` has a non-string key ({tool!r})")
222
+ continue
223
+ problems.extend(_entry_problems(tool, entry))
224
+ if not problems:
225
+ return None
226
+ # Bounded: a wholly corrupt block would otherwise put dozens of clauses
227
+ # into one `doctor` line. The count stays exact, so nothing is hidden.
228
+ head = "; ".join(problems[:3])
229
+ if len(problems) > 3:
230
+ head += f"; and {len(problems) - 3} more"
231
+ return head
232
+
233
+
234
+ def for_tool(table: dict[str, ArtifactProvenance] | None, tool: str) -> ArtifactProvenance:
235
+ """`table`'s entry for `tool`, or `UNKNOWN`. Takes `None` for a caller that
236
+ has no table at all (every pre-tan-cli#1066 call site, and every unit test
237
+ of the refusal builders), so "no table" and "no entry" cannot diverge."""
238
+ if not table:
239
+ return UNKNOWN
240
+ return table.get(tool, UNKNOWN)