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
tan/core/build_plan.py ADDED
@@ -0,0 +1,625 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """The build-plan CONSUMER model (alp-sdk metadata/schemas/build-plan-v1.schema.json).
3
+
4
+ Strict producer / tolerant consumer: the required keys are enforced, the
5
+ optional-but-always-emitted ones default cleanly, and an unsupported
6
+ schemaVersion is REFUSED rather than silently hand-ported around."""
7
+ import json
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from tan.core.plan_exec import ExecutionPolicy, PolicyAction
14
+
15
+ SUPPORTED_SCHEMA_VERSION = 1
16
+
17
+ #: The oracle types `schemaVersion` as a Rust `u32`, so the accepted domain is
18
+ #: exactly `[0, 2**32 - 1]` -- established by RUNNING it: `-1` and
19
+ #: `4294967296` are refused as `invalid value: integer ..., expected u32`,
20
+ #: while `4294967295` gets through the type check and lands on the
21
+ #: version-skew message instead.
22
+ _U32_MAX = 0xFFFFFFFF
23
+
24
+ _REQUIRED_TOP = (
25
+ "schemaVersion", "generatedBy", "boardYaml", "sku",
26
+ "buildRoot", "slices", "sharedArtefacts", "warnings",
27
+ )
28
+ _REQUIRED_SLICE = (
29
+ "coreId", "backend", "buildDir", "appDir", "configArtefacts",
30
+ "toolchain", "artifacts", "debug", "command", "env", "envAppendPath",
31
+ )
32
+
33
+ # The scalar fields Rust models as `String` / `Option<String>`. Typed here for
34
+ # the same reason serde types them there: unvalidated, a non-string escapes
35
+ # parse entirely and only detonates much later and far away -- `boardYaml` as
36
+ # an int reaches `str.replace` in the token pass, `sdkCommit` reaches
37
+ # `.strip()`, an unhashable `backend` reaches `in KNOWN_BACKENDS` -- as a bare
38
+ # AttributeError/TypeError in the executor's catch-all, i.e. reported as a tan
39
+ # bug (exit 5) when the truth is a malformed plan (`build.plan-invalid`,
40
+ # exit 1).
41
+ _REQUIRED_STR_TOP = ("generatedBy", "boardYaml", "sku", "buildRoot")
42
+ _OPTIONAL_STR_TOP = ("sdkVersion", "sdkCommit", "planPathMode")
43
+ _REQUIRED_STR_SLICE = ("coreId", "backend", "buildDir")
44
+ _OPTIONAL_STR_SLICE = ("appDir",)
45
+
46
+
47
+ class PlanParseError(Exception):
48
+ def __init__(self, code: str, message: str) -> None:
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.message = message
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class SliceCommand:
56
+ tool: str
57
+ args: list[str]
58
+ cwd: str | None
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Slice:
63
+ core_id: str
64
+ backend: str
65
+ build_dir: str
66
+ app_dir: str | None
67
+ config_artefacts: list[dict[str, Any]]
68
+ toolchain: Any
69
+ artifacts: dict[str, Any]
70
+ debug: dict[str, Any]
71
+ command: SliceCommand | None
72
+ env: dict[str, str]
73
+ env_append_path: dict[str, list[str]]
74
+ #: Steps that MUST run, in order, after `command` succeeds (alp-sdk
75
+ #: #1344, tan-cli#550). `west build` and `bitbake` configure AND build in
76
+ #: one invocation and carry none; a baremetal slice's `cmake -S ... -B .`
77
+ #: only CONFIGURES, so its `cmake --build .` arrives here.
78
+ #:
79
+ #: Defaulted rather than required -- the strict-producer/tolerant-consumer
80
+ #: rule this module's own docstring states. A plan emitted by an alp-sdk
81
+ #: predating #1344 has no `postCommands` key at all, and refusing it would
82
+ #: turn "your SDK is older than your tan" into `build.plan-invalid` for
83
+ #: every slice, including the zephyr/yocto ones whose correct value is
84
+ #: `[]` anyway.
85
+ #:
86
+ #: A tuple, not a list: this dataclass is `frozen=True`, and a mutable
87
+ #: default on a frozen dataclass is a `ValueError` at class-definition
88
+ #: time. Every consumer only iterates it.
89
+ post_commands: tuple[SliceCommand, ...] = ()
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class BuildPlan:
94
+ schema_version: int
95
+ generated_by: str
96
+ board_yaml: str
97
+ sku: str
98
+ build_root: str
99
+ slices: list[Slice]
100
+ shared_artefacts: list[dict[str, Any]]
101
+ warnings: list[dict[str, Any]]
102
+ sdk_version: str | None = None
103
+ sdk_commit: str | None = None
104
+ plan_path_mode: str | None = None
105
+ execution_policy: ExecutionPolicy | None = None
106
+ #: Top-level `deferredPlaceholders` (alp-sdk#2696): bare names of `${NAME}`
107
+ #: values the planner left unresolved on purpose. `None` means the plan
108
+ #: carries NO such key (older planner); a tuple (possibly empty) means it
109
+ #: does, and is then authoritative. A malformed value parses as `()` with
110
+ #: the reason in `deferred_placeholders_problem` -- never a licence to
111
+ #: exempt anything.
112
+ deferred_placeholders: tuple[str, ...] | None = None
113
+ deferred_placeholders_problem: str | None = None
114
+
115
+
116
+ #: A deferred-placeholder name, as alp-sdk#2696 spells it.
117
+ DEFERRED_PLACEHOLDER_NAME = re.compile(r"[A-Z][A-Z0-9_]*")
118
+
119
+
120
+ def _deferred_placeholders(raw: dict[str, Any]) -> tuple[tuple[str, ...] | None, str | None]:
121
+ """Parse the optional `deferredPlaceholders` list. Absent or `null` -> `(None, None)`.
122
+ Present but malformed -> `((), reason)`: the key IS there, so the plan is
123
+ authoritative and lists nothing, which exempts nothing."""
124
+ # `null` is ABSENT, like `sdkVersion`/`sdkCommit`: a tolerant consumer.
125
+ if raw.get("deferredPlaceholders") is None:
126
+ return None, None
127
+ value = raw["deferredPlaceholders"]
128
+ if not isinstance(value, list):
129
+ return (), "`deferredPlaceholders` must be a list of placeholder names"
130
+ for i, name in enumerate(value):
131
+ if not isinstance(name, str) or not DEFERRED_PLACEHOLDER_NAME.fullmatch(name):
132
+ return (), (
133
+ f"`deferredPlaceholders[{i}]` must be a string matching "
134
+ f"`^[A-Z][A-Z0-9_]*$`"
135
+ )
136
+ # The schema says unique; a duplicate is harmless, so it is folded, not refused.
137
+ return tuple(dict.fromkeys(value)), None
138
+
139
+
140
+ def _require_strings(
141
+ raw: dict[str, Any], required: tuple[str, ...], optional: tuple[str, ...], context: str
142
+ ) -> None:
143
+ """Type the scalar fields at parse time, where a mistyped one is still a
144
+ plan defect rather than a crash somewhere downstream. `context` is `""` at
145
+ the plan root and `slices[N]` inside a slice, so the message names the
146
+ field the way the plan spells it."""
147
+ prefix = f"{context}." if context else ""
148
+ for key in required:
149
+ if not isinstance(raw[key], str):
150
+ raise PlanParseError("build.plan-invalid", f"`{prefix}{key}` must be a string")
151
+ for key in optional:
152
+ if raw.get(key) is not None and not isinstance(raw[key], str):
153
+ raise PlanParseError("build.plan-invalid", f"`{prefix}{key}` must be a string or null")
154
+
155
+
156
+ def _action(raw: Any, context: str) -> PolicyAction | None:
157
+ """One `executionPolicy` entry, refused as a coded parse error rather than
158
+ a bare `ValueError` escaping the enum constructor.
159
+
160
+ This is a FORWARD-COMPATIBILITY trap, not a typo guard: the day the SDK
161
+ adds a fourth `executionPolicy` action, every plan carrying it hit
162
+ `PolicyAction(raw)` -> `ValueError` -> the caller's catch-all, so every
163
+ `tan build` against that SDK reported `build.internal-failure` at exit 5
164
+ -- a tan bug -- for a plan this binary merely does not understand yet.
165
+ Rust's serde refuses it as `build.plan-invalid` at exit 1; so does this."""
166
+ if raw is None:
167
+ return None
168
+ try:
169
+ return PolicyAction(raw)
170
+ except ValueError as err:
171
+ known = ", ".join(f"`{a.value}`" for a in PolicyAction)
172
+ raise PlanParseError(
173
+ "build.plan-invalid",
174
+ f"`{context}` must be one of {known} -- got `{raw}`. Upgrade tan if the SDK "
175
+ f"has added a newer execution policy.",
176
+ ) from err
177
+
178
+
179
+ def _policy(raw: Any) -> ExecutionPolicy | None:
180
+ if raw is None:
181
+ return None
182
+ if not isinstance(raw, dict):
183
+ raise PlanParseError("build.plan-invalid", "`executionPolicy` must be an object or null")
184
+ return ExecutionPolicy(
185
+ unknown_backend=_action(raw.get("unknownBackend"), "executionPolicy.unknownBackend"),
186
+ missing_tool=_action(raw.get("missingTool"), "executionPolicy.missingTool"),
187
+ null_command=_action(raw.get("nullCommand"), "executionPolicy.nullCommand"),
188
+ )
189
+
190
+
191
+ def _artefacts(raw: Any, context: str) -> list[dict[str, Any]]:
192
+ """Validate a `configArtefacts`/`sharedArtefacts` list at parse time
193
+ (Rust's serde rejects a malformed `GeneratedFile` here too) so a
194
+ malformed or null-valued entry fails as a coded `PlanParseError` instead
195
+ of an uncaught `KeyError`/`AttributeError` reaching `plan_tokens`, which
196
+ indexes `path`/`contents` unguarded."""
197
+ if not isinstance(raw, list):
198
+ raise PlanParseError("build.plan-invalid", f"`{context}` must be a list")
199
+ for i, art in enumerate(raw):
200
+ if (
201
+ not isinstance(art, dict)
202
+ or not isinstance(art.get("path"), str)
203
+ or not isinstance(art.get("contents"), str)
204
+ ):
205
+ raise PlanParseError(
206
+ "build.plan-invalid",
207
+ f"`{context}[{i}]` must be an object with string `path` and `contents` fields",
208
+ )
209
+ return raw
210
+
211
+
212
+ def _warnings(raw: Any) -> list[dict[str, Any]]:
213
+ """Validate `warnings` -- the LIST and now also every ENTRY.
214
+
215
+ `warnings` is a CONTRACT SURFACE, not merely an internal field: `tan
216
+ build` copies it verbatim into the envelope's `data.warnings`, and a
217
+ consumer doing `data.warnings ?? []` then `.map(w => w.code)` breaks on a
218
+ string. An earlier version checked only that the value was a list and
219
+ left the ENTRIES untyped "on purpose", reasoning that new warning codes
220
+ may appear without a schemaVersion bump. Running the oracle refutes the
221
+ second half: `{"warnings": ["oops"]}` is refused as `invalid type: string
222
+ "oops", expected struct PlanWarning`. The forward compatibility is in the
223
+ CODES being open, not the entry SHAPE -- measured, `PlanWarning` is a
224
+ 3-field struct: `code` and `message` are required strings, `coreId` is an
225
+ `Option<String>` (null and absent both fine), and an unknown key such as
226
+ `zzz` is accepted AND came back untouched in `data.warnings`. `code` is
227
+ checked before `message`, the order serde reports them in.
228
+
229
+ Deliberately NOT replicated: the oracle also accepts a 3-element ARRAY per
230
+ entry (serde's tuple form for the same struct -- hence its "expected
231
+ struct PlanWarning with 3 elements" wording). No producer emits that, the
232
+ schema says objects, and honouring it would mean carrying a second entry
233
+ shape through every consumer of `data.warnings`."""
234
+ if not isinstance(raw, list):
235
+ raise PlanParseError("build.plan-invalid", "`warnings` must be a list")
236
+ for i, entry in enumerate(raw):
237
+ if not isinstance(entry, dict):
238
+ raise PlanParseError(
239
+ "build.plan-invalid",
240
+ f"`warnings[{i}]` must be an object with string `code` and `message` fields",
241
+ )
242
+ for key in ("code", "message"):
243
+ if key not in entry:
244
+ raise PlanParseError(
245
+ "build.plan-invalid", f"`warnings[{i}]` is missing required key `{key}`"
246
+ )
247
+ if not isinstance(entry[key], str):
248
+ raise PlanParseError(
249
+ "build.plan-invalid", f"`warnings[{i}].{key}` must be a string"
250
+ )
251
+ if entry.get("coreId") is not None and not isinstance(entry["coreId"], str):
252
+ raise PlanParseError(
253
+ "build.plan-invalid", f"`warnings[{i}].coreId` must be a string or null"
254
+ )
255
+ return raw
256
+
257
+
258
+ def _foreign_os_absolute(tool: str) -> str | None:
259
+ """Return a short name for the OTHER OS's path convention if `tool`
260
+ looks absolute under it, else `None`.
261
+
262
+ `Path(tool).is_absolute()` (below) answers relative to THIS host's own
263
+ `pathlib` flavour: `/usr/bin/west` is absolute under `PurePosixPath` but
264
+ NOT under `PureWindowsPath` (no drive), and `C:\\tools\\west.exe` is
265
+ absolute under `PureWindowsPath` but NOT under `PurePosixPath` (no
266
+ leading `/`, and `pathlib` never special-cases a drive-letter prefix on
267
+ POSIX). Used only to name the reason precisely in the refusal message
268
+ below -- a bare "relative path" reading is misleading for a path that IS
269
+ absolute, just under the wrong OS's rules."""
270
+ if tool.startswith("/"):
271
+ return "POSIX"
272
+ if len(tool) >= 3 and tool[1] == ":" and tool[2] in "\\/" and tool[0].isalpha():
273
+ return "Windows"
274
+ if tool.startswith("\\\\"):
275
+ return "Windows"
276
+ return None
277
+
278
+
279
+ def _is_legal_env_name(name: str) -> bool:
280
+ """`os.environ`/`subprocess` reject an empty name, one containing `=`
281
+ (`ValueError: illegal environment variable name`) or one containing an
282
+ embedded NUL (`ValueError: embedded null byte`) -- catch that shape here,
283
+ at parse time, instead of at spawn time deep inside `execute.py`."""
284
+ return name != "" and "=" not in name and "\0" not in name
285
+
286
+
287
+ def _reject_nul(value: str, context: str) -> None:
288
+ """Refuse an embedded NUL in a string that is headed for `subprocess`.
289
+
290
+ Every C-level exec/environ interface takes NUL-terminated strings, so
291
+ CPython refuses the whole call with a bare `ValueError: embedded null
292
+ byte`. Measured before this fix, on `tan build --plan-from <f>
293
+ --execute`, a NUL in `env` (name OR value), `envAppendPath`, `command.args`
294
+ or `command.cwd` each produced `exit 5 build.internal-failure:
295
+ ValueError: embedded null byte` -- tan reporting ITSELF as broken for
296
+ what is a malformed plan. That is the same failure mode, and the same
297
+ remedy, as the `=`/empty env names `_is_legal_env_name` already refuses
298
+ two lines up.
299
+
300
+ ORACLE NOTE -- a MEASURED DIVERGENCE, stated plainly. v0.4.1 has no
301
+ `--execute` flag, so the oracle has no observable behaviour at all for a
302
+ NUL-bearing plan on the spawn path; the only path it can reach is plan
303
+ DISPLAY, where it echoes the NUL back at exit 0. This parse runs on that
304
+ path too, so `tan build --plan-from <nul-plan>` -- which implies display
305
+ in this port, exactly as it does in the oracle -- now exits 1 here where
306
+ the oracle exits 0.
307
+
308
+ **That surface is `--plan-from`, NOT `--plan`.** An earlier version of
309
+ this note named `tan build --plan --plan-from <nul-plan>` as the diverging
310
+ invocation; measured, `--plan` on `tan build` is now a RETIRED flag in
311
+ this port and answers `exit 2 build.flag-retired` for EVERY invocation,
312
+ valid or not (tan-cli#427), so nothing about a NUL is observable through
313
+ it.
314
+
315
+ The trade is taken deliberately, for the same reason and in the same shape
316
+ as the `=`/empty env names above, which already diverge from the oracle in
317
+ exactly this way (measured: `{"F=OO": "bar"}` is `exit 0 ok:true` on the
318
+ oracle and `exit 1 build.plan-invalid` here, and has been since before this
319
+ change). The alternative is keeping a `build.internal-failure` exit 5 on
320
+ the only path where the value is ever USED.
321
+
322
+ The divergence is deliberately NARROW, and three NUL-bearing fields are
323
+ still accepted here. What each one ACTUALLY does was re-measured; an
324
+ earlier version of this list described two of them wrongly:
325
+
326
+ * `configArtefacts[].path` and `sharedArtefacts[].path` -- the oracle
327
+ exits 3 `build.materialise-failed`, "file name contained an unexpected
328
+ NUL byte", so this parser must not pre-empt it with a vaguer refusal.
329
+ But this port does NOT reach that answer: measured on
330
+ `tan build --plan-from <f> --materialise`, both fields give
331
+ `exit 5 build.internal-failure: ValueError: embedded null byte`. They
332
+ are NOT "left to the materialiser" in any working sense -- they are a
333
+ LIVE defect of exactly the class this function exists to close, left
334
+ open because the fix belongs in `tan.commands.build.materialise`
335
+ (`confine_to_build_root`/`_write`), out of this lane's file.
336
+ * `command.tool` -- answered downstream: the slice is `skipped` with
337
+ `tool `<name>` not found`, the envelope carries a `build.missing-tool`
338
+ warning and a `build.nothing-built` error, exit 1. A NUL-bearing path
339
+ can never exist on disk, so the miss is guaranteed.
340
+ * `buildDir` -- no crash, and no failure either: measured, a slice with
341
+ a NUL `buildDir` RUNS and SUCCEEDS -- `exit 0`, `ok:true`, slice
342
+ status `ok`. Nothing on the execute path opens it (`plan_tokens.
343
+ _substitute_slice` substitutes it; `SliceOutcome.build_dir` comes from
344
+ `resolve_zephyr_artefact(cwd, ...)`, not from the plan), so the NUL is
345
+ carried into `data` and dropped. Left accepted rather than refused
346
+ because the value never reaches an OS call -- but "the slice fails on
347
+ its own terms", which this note used to claim, is simply not what
348
+ happens.
349
+ """
350
+ if "\0" in value:
351
+ raise PlanParseError(
352
+ "build.plan-invalid",
353
+ f"`{context}` contains an embedded NUL byte -- it cannot be handed to a "
354
+ f"process, its environment, or the filesystem. This is a malformed plan; "
355
+ f"re-emit it.",
356
+ )
357
+
358
+
359
+ def _validate_tool_shape(tool: str, context: str) -> None:
360
+ """Refuse a `command.tool` that is neither a bare identity nor an
361
+ already-resolved absolute path -- split out of `_command` so that
362
+ function stays under the repo's long-function budget.
363
+
364
+ tan-cli#510 review, MAJOR 4: `command.tool` is an IDENTITY per ADR-0020
365
+ (a bare name to look up, e.g. `west`) or an ALREADY-RESOLVED absolute
366
+ path a producer computed -- never a relative path. A relative path
367
+ carrying a separator (`bin/sh`) is neither: `_resolve_tool`
368
+ (`build/execute.py`) answers `Path(tool).is_absolute()` False for it, so
369
+ it falls into the bare-identity PATH search, which checks it against
370
+ THIS process's cwd -- but the spawn below hands it to
371
+ `subprocess.Popen(cwd=spawn_cwd)`, resolved against the CHILD's cwd
372
+ instead. Two different directories deciding what "the tool" even means
373
+ is the exact defect this issue exists to close; refused here, at parse
374
+ time, rather than reaching that check/spawn split at all. A bare `west`
375
+ (no separator) is untouched -- it is exactly the identity shape this
376
+ refusal is not about.
377
+
378
+ tan-cli#530, decision (a): "absolute" is deliberately THIS HOST's own
379
+ notion (`Path(tool).is_absolute()`) -- an already-absolute
380
+ `command.tool` is inherently host-specific even though the plan around
381
+ it is portable (see `_foreign_os_absolute`'s docstring for the full
382
+ reasoning). No emitting flow produces this shape today (the SDK planner
383
+ only emits bare `west`/`bitbake`/`cmake`); this guards a hand-authored
384
+ or foreign-host `--plan-from` file."""
385
+ if Path(tool).is_absolute() or ("/" not in tool and "\\" not in tool):
386
+ return
387
+ foreign = _foreign_os_absolute(tool)
388
+ if foreign is not None:
389
+ raise PlanParseError(
390
+ "build.plan-invalid",
391
+ f"`{context}.tool` (`{tool}`) is a {foreign}-absolute path, not "
392
+ f"executable on this host -- a build plan is a portable artefact, "
393
+ f"but an absolute `command.tool` is inherently host-specific: "
394
+ f"`{tool}` is only absolute under {foreign} path rules, and this "
395
+ f"host cannot resolve or spawn it. `command.tool` must be either "
396
+ f"a bare identity to look up on PATH (e.g. `west`) or a path this "
397
+ f"host itself recognises as absolute.",
398
+ )
399
+ raise PlanParseError(
400
+ "build.plan-invalid",
401
+ f"`{context}.tool` (`{tool}`) is a relative path, not an identity -- "
402
+ f"`command.tool` must be either a bare identity to look up on PATH "
403
+ f"(e.g. `west`) or an already-resolved absolute path; a relative path "
404
+ f"carrying a separator is neither.",
405
+ )
406
+
407
+
408
+ def _command(raw: Any, context: str) -> SliceCommand | None:
409
+ """Validate a slice's `command` (or `null`) at parse time. Rust's serde
410
+ rejects a malformed `ToolStep` the same way -- an unguarded `cmd["tool"]`
411
+ let a missing key escape as a bare `KeyError`, and a non-string
412
+ `tool`/`args` element/`cwd` reached `subprocess.Popen` as an uncaught
413
+ `TypeError`."""
414
+ if raw is None:
415
+ return None
416
+ if not isinstance(raw, dict):
417
+ raise PlanParseError("build.plan-invalid", f"`{context}` must be an object or null")
418
+ tool = raw.get("tool")
419
+ if not isinstance(tool, str):
420
+ raise PlanParseError("build.plan-invalid", f"`{context}.tool` must be a string")
421
+ _validate_tool_shape(tool, context)
422
+ args = raw.get("args", [])
423
+ if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
424
+ raise PlanParseError("build.plan-invalid", f"`{context}.args` must be a list of strings")
425
+ for i, arg in enumerate(args):
426
+ _reject_nul(arg, f"{context}.args[{i}]")
427
+ cwd = raw.get("cwd")
428
+ if cwd is not None and not isinstance(cwd, str):
429
+ raise PlanParseError("build.plan-invalid", f"`{context}.cwd` must be a string or null")
430
+ if cwd is not None:
431
+ _reject_nul(cwd, f"{context}.cwd")
432
+ return SliceCommand(tool=tool, args=list(args), cwd=cwd)
433
+
434
+
435
+ def _post_commands(raw: Any, context: str) -> tuple[SliceCommand, ...]:
436
+ """Validate a slice's `postCommands` (alp-sdk #1344, tan-cli#550).
437
+
438
+ Absent or `null` is `()`, not a refusal -- see [`Slice.post_commands`] for
439
+ why an older SDK's plan must still parse. Anything else must be a LIST,
440
+ and every entry a real command object: a `null` entry is refused here
441
+ rather than silently dropped, because `_command` answers `None` for a null
442
+ `command` (the plan's own "this slice has nothing to run" signal) and that
443
+ meaning does not exist inside a step list -- a dropped step is a build
444
+ step that never ran with nothing on the wire to say so, which is the exact
445
+ class of silence tan-cli#550 is about.
446
+
447
+ Each entry goes through the SAME `_command` validation as the slice's own
448
+ `command`: identical `tool` shape rules (bare identity or host-absolute,
449
+ never a relative path with a separator), identical `args`/`cwd` typing and
450
+ identical embedded-NUL refusal. They are handed to `subprocess` by the
451
+ same executor, so a shape this parser accepts here but not there would be
452
+ a hole, not a tolerance."""
453
+ if raw is None:
454
+ return ()
455
+ if not isinstance(raw, list):
456
+ raise PlanParseError(
457
+ "build.plan-invalid", f"`{context}` must be a list of command objects or null"
458
+ )
459
+ steps: list[SliceCommand] = []
460
+ for i, entry in enumerate(raw):
461
+ if entry is None:
462
+ raise PlanParseError(
463
+ "build.plan-invalid",
464
+ f"`{context}[{i}]` must be an object -- a null step is not a command, and "
465
+ f"a step that cannot run must be absent from the list, not present as null",
466
+ )
467
+ step = _command(entry, f"{context}[{i}]")
468
+ if step is not None:
469
+ steps.append(step)
470
+ return tuple(steps)
471
+
472
+
473
+ def _env(raw: Any, context: str) -> dict[str, str]:
474
+ if not isinstance(raw, dict) or not all(
475
+ isinstance(k, str) and _is_legal_env_name(k) and isinstance(v, str) for k, v in raw.items()
476
+ ):
477
+ raise PlanParseError(
478
+ "build.plan-invalid",
479
+ f"`{context}` must be an object mapping a legal env-var name to a string value",
480
+ )
481
+ for key, value in raw.items():
482
+ _reject_nul(value, f"{context}.{key}")
483
+ return dict(raw)
484
+
485
+
486
+ def _env_append_path(raw: Any, context: str) -> dict[str, list[str]]:
487
+ if not isinstance(raw, dict) or not all(
488
+ isinstance(k, str)
489
+ and _is_legal_env_name(k)
490
+ and isinstance(v, list)
491
+ and all(isinstance(x, str) for x in v)
492
+ for k, v in raw.items()
493
+ ):
494
+ raise PlanParseError(
495
+ "build.plan-invalid",
496
+ f"`{context}` must be an object mapping a legal env-var name to a list of strings",
497
+ )
498
+ for key, values in raw.items():
499
+ for i, value in enumerate(values):
500
+ _reject_nul(value, f"{context}.{key}[{i}]")
501
+ return {k: list(v) for k, v in raw.items()}
502
+
503
+
504
+ def _slice(raw: Any, i: int) -> Slice:
505
+ if not isinstance(raw, dict):
506
+ raise PlanParseError("build.plan-invalid", f"`slices[{i}]` must be an object")
507
+ missing = [k for k in _REQUIRED_SLICE if k not in raw]
508
+ if missing:
509
+ raise PlanParseError(
510
+ "build.plan-invalid",
511
+ f"slice is missing required key(s): {', '.join(missing)}",
512
+ )
513
+ _require_strings(raw, _REQUIRED_STR_SLICE, _OPTIONAL_STR_SLICE, f"slices[{i}]")
514
+ return Slice(
515
+ core_id=raw["coreId"], backend=raw["backend"], build_dir=raw["buildDir"],
516
+ app_dir=raw["appDir"],
517
+ config_artefacts=_artefacts(raw["configArtefacts"], f"slices[{i}].configArtefacts"),
518
+ toolchain=raw["toolchain"], artifacts=raw["artifacts"], debug=raw["debug"],
519
+ command=_command(raw["command"], f"slices[{i}].command"),
520
+ env=_env(raw["env"], f"slices[{i}].env"),
521
+ env_append_path=_env_append_path(raw["envAppendPath"], f"slices[{i}].envAppendPath"),
522
+ post_commands=_post_commands(raw.get("postCommands"), f"slices[{i}].postCommands"),
523
+ )
524
+
525
+
526
+ def _schema_version(raw: dict[str, Any]) -> int:
527
+ """Read and TYPE `schemaVersion`, then apply the version-skew guard.
528
+
529
+ The type check is not pedantry, it is the whole defect (tan-cli#491,
530
+ defect 9). This was a bare `version != SUPPORTED_SCHEMA_VERSION`, and in
531
+ Python `True == 1` and `1.0 == 1` are both true -- so a plan declaring
532
+ `"schemaVersion": true` or `"schemaVersion": 1.0` compared EQUAL to 1 and
533
+ was accepted as a v1 plan. Measured, the oracle refuses both outright:
534
+ `invalid type: boolean `true`, expected u32` and `invalid type: floating
535
+ point `1.0`, expected u32`.
536
+
537
+ `isinstance(True, int)` is True (bool subclasses int), so the bool test
538
+ must come first and cannot be folded into the int test.
539
+
540
+ The two codes are a deliberate split the oracle does not make -- it
541
+ answers every case below with `build.plan-invalid`. A value that is not a
542
+ u32 AT ALL is a malformed plan and keeps that code; a well-typed u32 that
543
+ simply is not 1 is a version SKEW, which this port reports as
544
+ `build.plan-unsupported-schema` so a consumer can tell "your SDK and your
545
+ tan disagree" from "this file is garbage". That split predates this fix
546
+ and is preserved exactly; what changes is which values reach it."""
547
+ if "schemaVersion" not in raw:
548
+ raise PlanParseError(
549
+ "build.plan-invalid",
550
+ "plan is missing required key(s): schemaVersion",
551
+ )
552
+ version = raw["schemaVersion"]
553
+ if isinstance(version, bool) or not isinstance(version, int):
554
+ raise PlanParseError(
555
+ "build.plan-invalid",
556
+ f"`schemaVersion` must be an integer -- got `{version!r}`. A build plan "
557
+ f"declares its schema as a whole number (the SDK emits "
558
+ f"{SUPPORTED_SCHEMA_VERSION}); re-emit the plan.",
559
+ )
560
+ if not 0 <= version <= _U32_MAX:
561
+ raise PlanParseError(
562
+ "build.plan-invalid",
563
+ f"`schemaVersion` ({version}) is outside the representable range "
564
+ f"0..{_U32_MAX}; re-emit the plan.",
565
+ )
566
+ if version != SUPPORTED_SCHEMA_VERSION:
567
+ raise PlanParseError(
568
+ "build.plan-unsupported-schema",
569
+ f"unsupported build-plan schemaVersion `{version}` (this tan supports "
570
+ f"{SUPPORTED_SCHEMA_VERSION}) -- refusing rather than falling back to "
571
+ f"hand-ported behaviour. Upgrade tan, or re-emit the plan.",
572
+ )
573
+ return version
574
+
575
+
576
+ def parse_build_plan(text: str) -> BuildPlan:
577
+ try:
578
+ raw = json.loads(text)
579
+ except ValueError as err:
580
+ raise PlanParseError("build.plan-invalid", f"plan is not valid JSON: {err}") from err
581
+
582
+ if not isinstance(raw, dict):
583
+ raise PlanParseError("build.plan-invalid", "plan is not a JSON object")
584
+
585
+ version = _schema_version(raw)
586
+
587
+ missing = [k for k in _REQUIRED_TOP if k not in raw]
588
+ if missing:
589
+ raise PlanParseError(
590
+ "build.plan-invalid",
591
+ f"plan is missing required key(s): {', '.join(missing)}",
592
+ )
593
+
594
+ _require_strings(raw, _REQUIRED_STR_TOP, _OPTIONAL_STR_TOP, "")
595
+ # tan-cli#491 defect 8, REVIEW round: a fourth live NUL vector of the same
596
+ # class as `env`/`envAppendPath`/`command.args`/`command.cwd`, missed by
597
+ # the first pass. `buildRoot` reaches `manifest.write_post_build_manifest`
598
+ # -> `confine_to_build_root(Path(base), plan_build_root)` -> `Path.resolve`
599
+ # after every dispatch, so measured on `tan build --plan-from <f>
600
+ # --execute` a NUL here gave `exit 5 build.internal-failure: ValueError:
601
+ # embedded null byte` -- tan reporting itself as broken for a malformed
602
+ # plan, the exact shape this defect is about. The oracle has no answer to
603
+ # pre-empt: it has no `--execute`, and on `--materialise` it never reads
604
+ # the plan's `buildRoot` at all (measured: `exit 0 ok:true`).
605
+ _reject_nul(raw["buildRoot"], "buildRoot")
606
+
607
+ if not isinstance(raw["slices"], list):
608
+ raise PlanParseError("build.plan-invalid", "`slices` must be a list")
609
+ # Validated HERE, ahead of the slices, purely to keep the pre-existing
610
+ # report order: a plan with both a bad warning and a bad slice named the
611
+ # warning first before this change, and still does.
612
+ warnings = _warnings(raw["warnings"])
613
+
614
+ deferred, deferred_problem = _deferred_placeholders(raw)
615
+
616
+ return BuildPlan(
617
+ schema_version=version, generated_by=raw["generatedBy"], board_yaml=raw["boardYaml"],
618
+ sku=raw["sku"], build_root=raw["buildRoot"],
619
+ slices=[_slice(s, i) for i, s in enumerate(raw["slices"])],
620
+ shared_artefacts=_artefacts(raw["sharedArtefacts"], "sharedArtefacts"),
621
+ warnings=warnings,
622
+ sdk_version=raw.get("sdkVersion"), sdk_commit=raw.get("sdkCommit"),
623
+ plan_path_mode=raw.get("planPathMode"), execution_policy=_policy(raw.get("executionPolicy")),
624
+ deferred_placeholders=deferred, deferred_placeholders_problem=deferred_problem,
625
+ )