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/planner/loader.py ADDED
@@ -0,0 +1,1525 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """board.yaml loader -- file IO, preset/silicon resolution, and load_board_yaml.
4
+
5
+ Parses board.yaml into a BoardProject: YAML/JSON IO, schema validation, the
6
+ `preset:` shared-board + inline-board resolution, silicon-ref -> SoC JSON path,
7
+ per-core topology defaults, and the big `load_board_yaml` entry point (which
8
+ finishes by running the cross-field validators). Extracted as the #285 loader
9
+ seam.
10
+
11
+ `load_board_schema` / `iter_schema_errors` are RELOCATED from alp-sdk's
12
+ `scripts/alp_cli/validator.py`, the last module-scope import this file made
13
+ across the repo boundary. They stay one pair of functions rather than being
14
+ inlined into `_validate_board` for the reason alp-sdk gave them their own
15
+ names: the schema FILE, the draft dialect, and the error ORDERING are one
16
+ decision, so every consumer reports identical violations in identical order.
17
+ The schema itself is still read from the bound SDK checkout (`BOARD_SCHEMA`) --
18
+ `metadata/**` did not move (ADR-0017).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any, Optional
27
+
28
+ try:
29
+ import yaml # type: ignore[import-untyped]
30
+ except ImportError:
31
+ sys.exit("alp_orchestrate: PyYAML is required. Install via `pip install pyyaml`.")
32
+
33
+ try:
34
+ import jsonschema # type: ignore[import-untyped]
35
+ except ImportError:
36
+ sys.exit("alp_orchestrate: jsonschema is required. Install via `pip install jsonschema`.")
37
+
38
+ from tan.core.metadata_schema import (
39
+ missing_schema_note,
40
+ soc_spec_schema_path,
41
+ som_preset_schema_path,
42
+ validate_document,
43
+ )
44
+
45
+ from . import sdk_compat
46
+ from .models import (
47
+ BoardProject,
48
+ IpcEntry,
49
+ OrchestratorError,
50
+ SdkRevisionNotBuildable,
51
+ SdkRevisionUnknown,
52
+ SdkRevisionUnsupported,
53
+ Slice,
54
+ StorageEntry,
55
+ )
56
+ from .ownership import load_ownership_doc, resolve_ownership
57
+ from .partition import _is_ospi_key_unassembled, _known_flash_devices
58
+ from .paths import BOARD_SCHEMA, METADATA_ROOT, REPO
59
+ from .som_metadata import _sku_family, resolve_memory_map
60
+ from .strict_loaders import DuplicateKeyError, strict_json_loads, strict_yaml_load
61
+ from .topology import _default_os_from_core_type
62
+ from .validate import (
63
+ _enforce_loader_rules,
64
+ _enforce_os_matches_core_class,
65
+ _validate_consistency,
66
+ )
67
+
68
+
69
+ def load_board_schema(schema_path: Path | None = None) -> dict[str, Any]:
70
+ """Load board.schema.json (the bound SDK's copy unless *schema_path* overrides).
71
+
72
+ The one place the schema file is resolved + parsed.
73
+ """
74
+ return json.loads((schema_path or BOARD_SCHEMA).read_text(encoding="utf-8"))
75
+
76
+
77
+ def iter_schema_errors(
78
+ data: dict[str, Any], schema_path: Path | None = None
79
+ ) -> list[jsonschema.ValidationError]:
80
+ """Validate *data* against board.schema.json; errors sorted by path.
81
+
82
+ The shared JSON-Schema pass: one schema file, one draft dialect
83
+ (2020-12, matching the schema's own `$schema` declaration), one error
84
+ ordering -- so every consumer reports identical violations.
85
+ """
86
+ validator = jsonschema.Draft202012Validator(load_board_schema(schema_path))
87
+ # Stringify path parts: absolute_path mixes ints (array indices) and
88
+ # strs (keys); a raw list comparison would TypeError across siblings.
89
+ return sorted(validator.iter_errors(data),
90
+ key=lambda e: [str(p) for p in e.absolute_path])
91
+
92
+
93
+ def _silicon_to_soc_path(silicon: str, metadata_root: Path) -> Path:
94
+ """`alif:ensemble:e7` -> metadata/socs/alif/ensemble/e7.json."""
95
+ parts = silicon.split(":")
96
+ if len(parts) != 3:
97
+ raise OrchestratorError(
98
+ f"silicon ref '{silicon}' is not a triple-colon string")
99
+ return (metadata_root / "socs" / parts[0] / parts[1] /
100
+ f"{parts[2]}.json")
101
+
102
+
103
+ # ---------------------------------------------------------------------
104
+ # Loader
105
+ # ---------------------------------------------------------------------
106
+
107
+
108
+ def _load_yaml(path: Path) -> dict[str, Any]:
109
+ try:
110
+ path_is_file = path.is_file()
111
+ except (OSError, RuntimeError) as e:
112
+ # Same defect class as validate.py's `profile:` guard (#1961):
113
+ # a bad `--input` board.yaml path must not crash the loader
114
+ # with an unhandled exception. `Path.is_file()`'s own
115
+ # `_ignore_error` list swallows ENOENT/ENOTDIR/EBADF/ELOOP on
116
+ # POSIX -- a symlink loop returns `False` there, no raise --
117
+ # but re-raises `PermissionError` on EACCES (an `OSError`
118
+ # subclass) rather than swallowing it, same as the `profile:`
119
+ # gap. On Windows, a real WSL-made symlink loop driven through
120
+ # the real CLI with CPython 3.11.3 shows `.is_file()` raise a
121
+ # plain `OSError` (`WinError 1920`, "The file cannot be
122
+ # accessed by the system") instead -- confirmed, not a mock.
123
+ # `RuntimeError` is caught too for parity with `.resolve()`'s
124
+ # own ELOOP shape elsewhere in this module.
125
+ raise OrchestratorError(f"could not access {path}: {e}") from e
126
+ if not path_is_file:
127
+ raise OrchestratorError(f"file not found: {path}")
128
+ try:
129
+ data = strict_yaml_load(path.read_text(encoding="utf-8"), source=path)
130
+ except (yaml.YAMLError, DuplicateKeyError) as e:
131
+ raise OrchestratorError(f"failed to parse {path}: {e}") from e
132
+ if not isinstance(data, dict):
133
+ raise OrchestratorError(
134
+ f"{path} did not parse to a top-level mapping")
135
+ return data
136
+
137
+
138
+ def _load_json(path: Path) -> dict[str, Any]:
139
+ if not path.is_file():
140
+ raise OrchestratorError(f"file not found: {path}")
141
+ try:
142
+ return strict_json_loads(path.read_text(encoding="utf-8"), source=path)
143
+ except (json.JSONDecodeError, DuplicateKeyError) as e:
144
+ raise OrchestratorError(f"failed to parse {path}: {e}") from e
145
+
146
+
147
+ def _validate_board(project: dict[str, Any],
148
+ metadata_root: Path = METADATA_ROOT) -> None:
149
+ schema_path = metadata_root / "schemas" / "board.schema.json"
150
+ if not schema_path.is_file():
151
+ # Fall back to the global schema when the metadata_root doesn't
152
+ # carry its own copy (e.g. synthetic test roots without schemas/).
153
+ schema_path = BOARD_SCHEMA
154
+ errors = iter_schema_errors(project, schema_path)
155
+ if errors:
156
+ messages = []
157
+ for e in errors:
158
+ loc = "/".join(str(p) for p in e.path) or "<root>"
159
+ messages.append(f" - {loc}: {e.message}")
160
+ raise OrchestratorError(
161
+ "board.yaml schema validation failed:\n" +
162
+ "\n".join(messages))
163
+
164
+
165
+ def _resolve_board_preset(
166
+ preset: str,
167
+ metadata_root: Path,
168
+ ) -> dict[str, Any]:
169
+ """Load the shared board YAML referenced by `preset:`.
170
+
171
+ Shared boards live at metadata/boards/<preset>.yaml. Raises
172
+ OrchestratorError when the file is missing (`preset:` must resolve;
173
+ customers with a custom board define it inline instead).
174
+ """
175
+ p = metadata_root / "boards" / f"{preset}.yaml"
176
+ if not p.is_file():
177
+ raise OrchestratorError(
178
+ f"`preset: {preset}` does not resolve: no shared board "
179
+ f"at {p.relative_to(REPO) if p.is_relative_to(REPO) else p}. "
180
+ f"Available presets: "
181
+ f"{sorted(_available_presets(metadata_root))}")
182
+ return _load_yaml(p)
183
+
184
+
185
+ def _available_presets(metadata_root: Path) -> list[str]:
186
+ boards_dir = metadata_root / "boards"
187
+ if not boards_dir.is_dir():
188
+ return []
189
+ return [p.stem for p in boards_dir.glob("*.yaml")]
190
+
191
+
192
+ def _check_board_hosts_som_family(
193
+ sku: str,
194
+ som_preset: dict[str, Any],
195
+ preset: str,
196
+ board_preset: dict[str, Any],
197
+ ) -> None:
198
+ family = som_preset.get("family")
199
+ allowed_raw = board_preset.get("hosts_som_families")
200
+ if not isinstance(family, str) or not isinstance(allowed_raw, list):
201
+ return
202
+ allowed = [str(item) for item in allowed_raw]
203
+ if family in allowed:
204
+ return
205
+ raise OrchestratorError(
206
+ f"board preset '{preset}' hosts SoM families {allowed}, "
207
+ f"but {sku} is family '{family}'")
208
+
209
+
210
+ def _synthesize_inline_board(project: dict[str, Any]) -> dict[str, Any]:
211
+ """Build a board-shaped dict from a project's inline top-level fields.
212
+
213
+ Used when board.yaml has no `preset:` -- the project's own `name`,
214
+ `populated`, `e1m_routes` (and optional `hw_rev`) double as the
215
+ board definition. Returned dict has the same shape downstream
216
+ emitters expect from a preset-resolved board.
217
+ """
218
+ return {
219
+ "name": project.get("name"),
220
+ "populated": dict(project.get("populated") or {}),
221
+ "e1m_routes": dict(project.get("e1m_routes") or {}),
222
+ # Inline mode has no per-board hw_revisions table; loader code
223
+ # that reads default_hw_rev / hw_revisions tolerates None.
224
+ "default_hw_rev": project.get("hw_rev"),
225
+ }
226
+
227
+
228
+ def _resolve_topology_for_core(
229
+ core_id: str,
230
+ project_cores: dict[str, Any],
231
+ som_topology: dict[str, Any],
232
+ ) -> Optional[dict[str, Any]]:
233
+ """Per spec §4.5: project's `cores.<id>` overrides the SoM preset's
234
+ `topology.<id>`. Returns None when neither source has the key."""
235
+ if core_id in project_cores:
236
+ # Customer-supplied entry; merge missing keys from topology.
237
+ merged: dict[str, Any] = dict(som_topology.get(core_id, {}) or {})
238
+ merged.update(project_cores[core_id] or {})
239
+ return merged
240
+ if core_id in som_topology:
241
+ return dict(som_topology[core_id] or {})
242
+ return None
243
+
244
+
245
+ def _resolve_variant_debug(
246
+ som_preset: dict[str, Any],
247
+ soc_spec: dict[str, Any],
248
+ ) -> dict[str, Any]:
249
+ """Resolve the whole `variants[].debug` block for the SoC variant this
250
+ SoM preset declares -- `{}` when the variant can't be resolved or
251
+ publishes no `debug:` block at all.
252
+
253
+ Matches the SoM's `silicon_variant:` against `soc_spec["variants"][].
254
+ order_code`, falling back to `alp_module_skus` membership when
255
+ `silicon_variant:` is absent/"TBD" -- the same two-step match as the
256
+ canonical `project_loader._resolve_silicon_variant` (alp-sdk's
257
+ `alp_project_loader`, re-exported there as
258
+ `alp_project._resolve_silicon_variant`) and `gen_zephyr_board.
259
+ _resolve_variant`. NOT delegated to that canonical helper -- unlike
260
+ `resolve_memory_map` (imported from `som_metadata` at the top of this
261
+ same file, so a cross-module import is clearly not the obstacle), it
262
+ takes `(sku_preset, metadata_root)` and re-resolves the SoC-JSON path +
263
+ reads + re-parses it from disk itself. By the time this function runs,
264
+ `_resolve_board_impl` has already loaded that exact JSON into the
265
+ `soc_spec` dict this function receives as a parameter, and this
266
+ function's callers have no other use for `metadata_root`. Delegating
267
+ would mean a second disk read + parse of a file already in hand, purely
268
+ to save this six-line match -- kept independent instead, deliberately in
269
+ lockstep with both of the above; if either one grows a third fallback or
270
+ drops the "TBD" sentinel, update this one too.
271
+
272
+ ONE variant match shared by every `debug:` reader below
273
+ (`_resolve_jlink_flash_device`, `_resolve_flow_d_preflight`) so a future
274
+ third fact cannot drift onto a differently-resolved variant than its
275
+ siblings -- the keys are read as a set precisely because
276
+ `expect_dpidr`/`jlink_device` are validated downstream as a PAIR.
277
+ """
278
+ variants = soc_spec.get("variants") or []
279
+ variant: Optional[dict[str, Any]] = None
280
+ declared = som_preset.get("silicon_variant")
281
+ # Case/whitespace-insensitive TBD match (alp-sdk #1048): "tbd"/"Tbd"/
282
+ # " TBD " are the same hand-typed placeholder as a bare "TBD".
283
+ declared_is_tbd = isinstance(declared, str) and declared.strip().upper() == "TBD"
284
+ if declared and not declared_is_tbd:
285
+ variant = next(
286
+ (v for v in variants if v.get("order_code") == declared), None)
287
+ if variant is None:
288
+ sku = som_preset.get("sku")
289
+ variant = next(
290
+ (v for v in variants if sku in (v.get("alp_module_skus") or [])),
291
+ None)
292
+ if variant is None:
293
+ return {}
294
+ return dict(variant.get("debug") or {})
295
+
296
+
297
+ def _resolve_jlink_flash_device(debug: dict[str, Any]) -> Optional[str]:
298
+ """`debug.jlink_flash_device` out of an already-resolved `debug:` block
299
+ (`_resolve_variant_debug`).
300
+
301
+ `jlink_flash_device` is a single per-variant string (per
302
+ soc-spec-v1.schema.json's own description: it "is not per-core... a
303
+ single per-variant string, not a jlink_device-shaped map"), unlike its
304
+ sibling `jlink_device` which IS keyed by core id -- so this returns one
305
+ value reused for every core's slice, not a per-core lookup.
306
+
307
+ None when the variant couldn't be resolved (`debug` is then `{}`), or
308
+ the resolved variant's `debug:` block carries no `jlink_flash_device`
309
+ key -- per that same schema description, an absent key is the correct
310
+ published "unknown", never a value to invent from a naming convention.
311
+ """
312
+ return debug.get("jlink_flash_device")
313
+
314
+
315
+ def _jlink_flash_device_declared(debug: dict[str, Any]) -> bool:
316
+ """Whether the resolved `debug:` block carried the `jlink_flash_device`
317
+ KEY, independent of its value (tan-cli#734).
318
+
319
+ `_resolve_jlink_flash_device` above cannot answer this: `dict.get`
320
+ returns `None` both for a schema-declared `jlink_flash_device: null` and
321
+ for an absent key, and those two mean opposite things. Declared-null is
322
+ a published "no known J-Link flash profile, refuse loudly"; absent is
323
+ "this variant says nothing", which keeps the Flow A default. The
324
+ downstream contract is `flash_plan._fa_has_key`, which is presence-based
325
+ for exactly this reason -- this is its counterpart on the emitter side.
326
+ """
327
+ return "jlink_flash_device" in debug
328
+
329
+
330
+ def _resolve_slot0_load_address(
331
+ som_preset: dict[str, Any], core_id: str,
332
+ ) -> Optional[str]:
333
+ """This core's AEN MRAM slot0-XIP load address, as a `0x`-prefixed hex
334
+ string for `flash_args.slot0_load_address` (tan-cli#353): the Flow D
335
+ built-in MRAM loader (`alif_mram_jlink`) needs to know where the
336
+ slot0-linked application blob itself belongs, distinct from
337
+ `jlink_flash_device` (which only picks the loader's *device profile*).
338
+
339
+ Deliberately sourced from the SoM preset's `memory_map:`, NOT from the
340
+ SoC JSON `debug:` block `jlink_flash_device` lives in: this address is
341
+ SDK/module build POLICY, not a silicon fact -- two SoMs built on the
342
+ same silicon part can freely choose different slot0 windows (alp-sdk
343
+ #1069, the fix for a stock symmetric layout that put HE's and HP's
344
+ slot0 at the SAME address and let flashing one silently clobber the
345
+ other).
346
+
347
+ Reuses `zephyr_board.py`'s own `_aen_role_slot0_map` (lazy import, to
348
+ dodge the circular import -- `zephyr_board.py` imports `_load_yaml`
349
+ from this module at module scope) rather than re-deriving its per-role
350
+ override lookup, so this and board generation read the identical
351
+ override/no-override decision and genuinely cannot disagree on THAT
352
+ part. A declared-but-invalid override (a half-authored map naming only
353
+ one role's `<role>_slot0`, or a wrong `accessible_from`) makes
354
+ `_aen_role_slot0_map` raise `ZephyrBoardEmitError` -- board generation
355
+ refuses to build that core's board file for the same reason, so this
356
+ re-raises as `OrchestratorError` instead of silently publishing an
357
+ address no board was actually built for.
358
+
359
+ Only `m55_he`/`m55_hp` roles are AEN slot0-XIP cores; every other
360
+ core_id (a32_cluster, non-AEN SoC families) returns None -- a
361
+ published "unknown", never a value to invent (same convention as
362
+ `jlink_flash_device`'s absent key).
363
+
364
+ No-override default: when `_aen_role_slot0_map` finds no per-role
365
+ override, this returns the stock symmetric layout's slot0 window --
366
+ right after the mcuboot region, at the MRAM base -- computed as
367
+ `zephyr_board._AEN_MRAM_BASE + zephyr_board._AEN_MCUBOOT_KIB * 1024`
368
+ directly against those two imported constants, not a locally pinned
369
+ literal, so a change to either constant moves this default too.
370
+ Applies uniformly to EVERY role in the no-override case (not just
371
+ `he`): the stock layout has exactly one slot0 window, and whichever
372
+ M55 core a SoM boots lands on it -- including an `m55_hp` that boots
373
+ alone, the case a role-keyed (`he`-only) fallback would still refuse.
374
+ (This sentence used to call E1M-AEN401/E1M-AEN601 "single-M55"; both
375
+ are DUAL-M55 -- see the paragraph below -- and alp-sdk#1446 cites
376
+ that wrong clause as the justification for having put two cores'
377
+ slot0 at one address until alp-sdk#1445.)
378
+
379
+ E1M-AEN401/E1M-AEN601 declare BOTH `m55_hp` and `m55_he` in
380
+ `topology:` (only the HP board's Zephyr tree is generated today --
381
+ alp-sdk#999, a separate, tracked gap), and since alp-sdk#1445 both
382
+ also declare per-role `he_slot0`/`hp_slot0` overrides in
383
+ `memory_map:` (metadata/e1m_modules/E1M-AEN401.yaml,
384
+ E1M-AEN601.yaml), so this resolves to DISJOINT addresses for the two
385
+ roles, not the no-override default above. The no-override branch is
386
+ reachable only by a FUTURE AEN variant that publishes
387
+ `jlink_flash_device` without also declaring a disjoint-slot0
388
+ override -- `_validate_topology_cores` only calls this resolver once
389
+ the SoC variant publishes `jlink_flash_device`, and every AEN preset
390
+ already declares the override. (Do not read that as a list of the
391
+ variants which publish `jlink_flash_device`: E4 does not --
392
+ `metadata/socs/alif/ensemble/e4.json` declares
393
+ `"jlink_flash_device": null`, so AEN401 never reaches this resolver
394
+ -- while E3/E5/E7 do, for AEN301/AEN501/AEN701.) That residual risk
395
+ is tracked, not silently accepted: alp-sdk#1384.
396
+ """
397
+ if not core_id.startswith("m55_"):
398
+ return None
399
+ role = core_id[len("m55_"):]
400
+ if role not in ("he", "hp"):
401
+ return None
402
+
403
+ from .zephyr_board import (
404
+ _AEN_MCUBOOT_KIB, _AEN_MRAM_BASE, ZephyrBoardEmitError,
405
+ _aen_role_slot0_map)
406
+
407
+ memory_map = som_preset.get("memory_map") or []
408
+ try:
409
+ slot0_region = _aen_role_slot0_map(memory_map, role)
410
+ except ZephyrBoardEmitError as exc:
411
+ raise OrchestratorError(
412
+ f"cannot resolve flash_args.slot0_load_address for "
413
+ f"{core_id!r}: {exc}") from exc
414
+
415
+ if slot0_region is not None:
416
+ return f"0x{slot0_region['base']:08x}"
417
+ return f"0x{_AEN_MRAM_BASE + _AEN_MCUBOOT_KIB * 1024:08x}"
418
+
419
+
420
+ def _resolve_flow_d_preflight(
421
+ debug: dict[str, Any],
422
+ core_id: str,
423
+ ) -> tuple[Optional[str], Optional[str]]:
424
+ """The read-only SW-DP IDR preflight PAIR for one core, out of an
425
+ already-resolved `debug:` block: `(expect_dpidr, jlink_device)`.
426
+
427
+ Two facts, one guard (alp-sdk #1355). `expect_dpidr` is the DPIDR the
428
+ board's debug port must answer before a host flasher writes anything;
429
+ `jlink_device` is the LIVE-CORE attach profile the read is performed
430
+ with -- distinct from `jlink_flash_device`, which is the part-number
431
+ flash-algorithm profile and cannot attach to a running core on an older
432
+ J-Link DLL. A consumer needs both or the check cannot run, and tan
433
+ (`tan.core.flash_plan.validate_flow_d_preflight_args`) hard-refuses a
434
+ half-armed pair -- so emitting one without the other does not merely
435
+ leave the guard unarmed, it bricks the flash path outright.
436
+
437
+ Hence both-or-neither, unconditionally: either key missing yields
438
+ `(None, None)`. Note `debug.jlink_device` is legitimately sparse across
439
+ `cores[]` -- an AEN variant publishes an attach profile for
440
+ `m55_hp`/`m55_he` and none for `a32_cluster`, which is a Cortex-A
441
+ cluster running Yocto, not a J-Link flash target -- so "no attach
442
+ profile for THIS core" is a normal answer, not an error, and this
443
+ function must not treat it as one.
444
+ """
445
+ expect_dpidr = debug.get("expect_dpidr")
446
+ jlink_device = (debug.get("jlink_device") or {}).get(core_id)
447
+ if not expect_dpidr or not jlink_device:
448
+ return (None, None)
449
+ return (expect_dpidr, jlink_device)
450
+
451
+
452
+ def _enforce_flow_d_preflight_pair(
453
+ slice_: Slice,
454
+ debug: dict[str, Any],
455
+ sku: str,
456
+ ) -> None:
457
+ """Refuse a Flow-D-armed Zephyr slice that silently dropped the
458
+ wrong-board preflight because its core has no attach profile (alp-sdk
459
+ #1355).
460
+
461
+ `_resolve_flow_d_preflight`'s both-or-neither rule is what keeps the
462
+ emitted `flash_args` legal, but on its own it would also let a REAL gap
463
+ -- a variant that publishes `expect_dpidr` and forgets (or loses, to a
464
+ core rename) the `jlink_device` entry for a core that genuinely flashes
465
+ -- degrade quietly back to an unguarded write. That is the exact
466
+ failure this issue exists to remove, so it must be loud.
467
+
468
+ Scoped to the slices where the guard actually applies: `os: zephyr`
469
+ (Flow D is a Zephyr-on-M path) whose SoC variant publishes
470
+ `jlink_flash_device` (that key's presence IS what promotes a
471
+ `zephyr_west_flash` entry to Flow D). An A-core, an `os: off` core, or
472
+ a part with no J-Link MRAM loader at all is not a Flow D target and is
473
+ correctly silent here.
474
+
475
+ The converse -- `jlink_device` with no `expect_dpidr` -- is deliberately
476
+ NOT an error: that is the normal, correct state of every variant whose
477
+ DPIDR nobody has measured yet, and it simply leaves the guard unarmed.
478
+ An unarmed guard is recoverable; a guard armed at a guessed ID is not,
479
+ because a wrong ID that happens to match another board on the same
480
+ bench passes on exactly the board it exists to exclude.
481
+ """
482
+ if slice_.os != "zephyr" or not (
483
+ slice_.jlink_flash_device_declared or slice_.jlink_flash_device is not None
484
+ ):
485
+ return
486
+ # Bound once and read from the local: interpolating `debug['expect_dpidr']`
487
+ # into the message would make a mis-edited condition fail with a KeyError
488
+ # raised from inside its own diagnostic, instead of with the diagnostic.
489
+ expect_dpidr = debug.get("expect_dpidr")
490
+ if expect_dpidr and not slice_.jlink_device:
491
+ raise OrchestratorError(
492
+ f"{sku}: SoC variant publishes debug.expect_dpidr "
493
+ f"({expect_dpidr}) but no debug.jlink_device entry for "
494
+ f"core '{slice_.core_id}', which flashes over Flow D -- the "
495
+ f"wrong-board SW-DP IDR preflight needs BOTH (the expected ID "
496
+ f"and the live-core attach profile it is read with), and a "
497
+ f"downstream flasher refuses a half-armed pair outright rather "
498
+ f"than skipping the check. Add "
499
+ f"debug.jlink_device['{slice_.core_id}'] to this variant in "
500
+ f"metadata/socs/, or remove debug.expect_dpidr.")
501
+
502
+
503
+ def _enforce_slot0_disjoint_across_roles(
504
+ cores: dict[str, Slice],
505
+ sku: str,
506
+ ) -> None:
507
+ """Refuse a dual-M55 AEN SoM whose `m55_he` and `m55_hp` slices publish
508
+ the SAME `flash_args.slot0_load_address` (alp-sdk #1384).
509
+
510
+ alp-sdk#1295 made this reachable for the first time: it populated
511
+ `debug.jlink_flash_device` for the E3/E5/E6/E7 variants too, not only
512
+ E1M-AEN801's, so the no-override default -- deliberately the SAME
513
+ address for both roles (see `_resolve_slot0_load_address`'s docstring)
514
+ -- can now meet a live `jlink_flash_device`.
515
+
516
+ Scoped to `os == "zephyr"` on BOTH roles (alp-sdk#1445, ported here as
517
+ tan-cli#744): a parked (`os: "off"`) or non-Zephyr core produces no
518
+ flashable artifact, so its resolved `slot0_load_address` is moot and
519
+ nothing would ever write to it. Comparing it anyway would refuse
520
+ `examples/power-timing/power-managed-sensor` -- a real, working app
521
+ that parks `m55_hp` -- for a collision that cannot physically happen.
522
+ Mirrors `_enforce_flow_d_preflight_pair`'s own `slice_.os != "zephyr"`
523
+ guard, this file's established convention for "only a live Zephyr slice
524
+ is a Flow D target".
525
+
526
+ Historical note, kept because it is why the guard long looked dead:
527
+ before alp-sdk#1295 the only AEN variant publishing
528
+ `jlink_flash_device` was E1M-AEN801's, and that SoM also declares a
529
+ disjoint `he_slot0`/`hp_slot0` `memory_map:` override -- so the shared
530
+ default address never met a live `jlink_flash_device` at all.
531
+
532
+ Still a real guard: a future AEN
533
+ variant that publishes `jlink_flash_device` without also declaring a
534
+ disjoint-slot0 override would otherwise silently reintroduce #1069's
535
+ HE/HP MRAM collision in `flash_args` -- flashing one core would corrupt
536
+ the other's slot0 window with no signal at all, the exact silent-wrong-
537
+ address class this file's other guards (`_enforce_flow_d_preflight_pair`,
538
+ `_resolve_slot0_load_address`'s own `OrchestratorError` on a half-
539
+ authored override) all exist to remove.
540
+ """
541
+ he = cores.get("m55_he")
542
+ hp = cores.get("m55_hp")
543
+ if (he is None or hp is None
544
+ or he.os != "zephyr" or hp.os != "zephyr"
545
+ or he.slot0_load_address is None
546
+ or hp.slot0_load_address is None):
547
+ return
548
+ if he.slot0_load_address == hp.slot0_load_address:
549
+ raise OrchestratorError(
550
+ f"{sku}: m55_he and m55_hp both resolve "
551
+ f"flash_args.slot0_load_address to the same address "
552
+ f"({he.slot0_load_address}) -- this is the #1069 HE/HP MRAM "
553
+ f"slot0 collision (flashing one core would silently corrupt "
554
+ f"the other's slot0 window). Declare disjoint `he_slot0`/"
555
+ f"`hp_slot0` regions in this SoM preset's `memory_map:` before "
556
+ f"this SoM can publish debug.jlink_flash_device.")
557
+
558
+
559
+ def _slice_from_resolved(
560
+ core_id: str,
561
+ entry: dict[str, Any],
562
+ soc_core_type: str = "",
563
+ jlink_flash_device: Optional[str] = None,
564
+ jlink_flash_device_declared: bool = False,
565
+ expect_dpidr: Optional[str] = None,
566
+ jlink_device: Optional[str] = None,
567
+ slot0_load_address: Optional[str] = None,
568
+ ) -> Slice:
569
+ """Build a Slice dataclass from the resolved per-core entry.
570
+
571
+ When `entry["os"]` is missing/empty, the OS is inferred from
572
+ `soc_core_type` via `_default_os_from_core_type()` (cortex-m* ->
573
+ zephyr, cortex-a* -> yocto, else "off"). Passing the empty
574
+ string for `soc_core_type` preserves the historical default of
575
+ "off".
576
+
577
+ `jlink_flash_device` is the caller's already-resolved
578
+ `_resolve_jlink_flash_device()` result (see that docstring) -- None
579
+ when this SoC variant publishes no J-Link flash-device profile.
580
+
581
+ `expect_dpidr` / `jlink_device` are the caller's already-resolved
582
+ `_resolve_flow_d_preflight()` PAIR for this core -- both None (the
583
+ preflight is not armed) or both set; never one of the two, which is
584
+ the half-armed shape a downstream flasher refuses (alp-sdk #1355).
585
+
586
+ `slot0_load_address` is the caller's already-resolved
587
+ `_resolve_slot0_load_address()` result for THIS core_id -- None when
588
+ this core has no AEN MRAM slot0-XIP window to publish.
589
+ """
590
+ return Slice(
591
+ core_id=core_id,
592
+ os=str(entry.get("os") or _default_os_from_core_type(soc_core_type)),
593
+ app=entry.get("app"),
594
+ image=entry.get("image"),
595
+ recipe=entry.get("recipe"),
596
+ machine=entry.get("machine"),
597
+ board=entry.get("board"),
598
+ toolchain=entry.get("toolchain"),
599
+ peripherals=list(entry.get("peripherals") or []),
600
+ libraries=list(entry.get("libraries") or []),
601
+ extra_libraries=[dict(e) for e in (entry.get("extra_libraries") or [])],
602
+ inference=dict(entry.get("inference") or {}),
603
+ iot=dict(entry.get("iot") or {}),
604
+ memory=dict(entry.get("memory") or {}),
605
+ power=dict(entry.get("power") or {}),
606
+ # Absent -> True (the core has a HW console); only a SoM preset's
607
+ # `topology.<id>.hw_console: false` marks a headless core.
608
+ hw_console=bool(entry.get("hw_console", True)),
609
+ jlink_flash_device=jlink_flash_device,
610
+ jlink_flash_device_declared=jlink_flash_device_declared,
611
+ expect_dpidr=expect_dpidr,
612
+ jlink_device=jlink_device,
613
+ slot0_load_address=slot0_load_address,
614
+ )
615
+
616
+
617
+ def _load_and_validate_yaml(path: Path,
618
+ metadata_root: Path) -> dict[str, Any]:
619
+ """Stage 1 of the #673 Phase-1 `load_board_yaml` split: read
620
+ board.yaml and run schema validation.
621
+
622
+ Pass metadata_root so test stubs using non-production SKU patterns
623
+ (E1M-TST001 etc.) validate against their own copy of the schema
624
+ rather than the repo's strict pattern.
625
+ """
626
+ path = Path(path)
627
+ project = _load_yaml(path)
628
+ _validate_board(project, metadata_root=metadata_root)
629
+ return project
630
+
631
+
632
+ def _resolve_board(
633
+ project: dict[str, Any],
634
+ metadata_root: Path,
635
+ *, skip_advisories: list[str] | None = None,
636
+ ) -> tuple[str, Optional[str], dict[str, Any], str, dict[str, Any],
637
+ dict[str, Any], Optional[str], Optional[str]]:
638
+ """Stage 2 of the #673 Phase-1 `load_board_yaml` split: SoM SKU
639
+ preset, SoC spec, and board (preset or inline) resolution.
640
+
641
+ Returns (sku, hw_rev, som_preset, silicon, soc_spec, board_preset,
642
+ board_name, board_hw_rev).
643
+
644
+ *skip_advisories*: threaded straight to `_resolve_board_impl`'s own
645
+ `_refuse_on_schema_errors` calls (tan-cli#964 review, major 6).
646
+ """
647
+ return _resolve_board_impl(project, metadata_root, skip_advisories=skip_advisories)
648
+
649
+
650
+ def _sku_family_dir(sku: str) -> Optional[str]:
651
+ """The SoM-family directory name for `sku`, or None when unrecognised.
652
+
653
+ Shared by `_check_hw_rev_exists` and `_check_sdk_supports_hw_rev`: an
654
+ unrecognised SKU is `_resolve_board`'s error to raise, not either
655
+ gate's to pre-empt with a worse message, so both stay quiet on
656
+ `ValueError` rather than failing here.
657
+ """
658
+ try:
659
+ return _sku_family(sku)
660
+ except ValueError:
661
+ return None
662
+
663
+
664
+ def _check_hw_rev_exists(
665
+ metadata_root: Path,
666
+ *,
667
+ sku: str,
668
+ som_hw_rev: Optional[str],
669
+ board_name: Optional[str],
670
+ board_hw_rev: Optional[str],
671
+ board_preset: Optional[dict[str, Any]],
672
+ ) -> None:
673
+ """Refuse an hw_rev that isn't a key in its resolved `hw_revisions:` table.
674
+
675
+ An hw_rev absent from the table used to silently resolve to empty
676
+ overrides -- base-revision pad routing with a clean exit code, i.e. a
677
+ wrong-hardware emit (alp-sdk #1025, the safe half). Runs BEFORE
678
+ `_check_sdk_supports_hw_rev`'s min/max comparison: an unknown revision
679
+ has no declared range to compare against, so reporting it as
680
+ out-of-range would name the wrong cause.
681
+
682
+ Existence-only, deliberately blind to `status:` -- a revision that
683
+ EXISTS passes regardless of being `status: reserved`, `status: tbd`,
684
+ or carrying no `status` key at all. Skips a side entirely when that
685
+ side's table doesn't exist to check against (inline boards carry no
686
+ per-board `hw_revisions:` table).
687
+ """
688
+ family_dir = _sku_family_dir(sku)
689
+
690
+ known = sdk_compat.family_revision_known(metadata_root, family_dir, som_hw_rev)
691
+ if known is False:
692
+ available = sdk_compat.family_available_revisions(metadata_root, family_dir)
693
+ raise SdkRevisionUnknown(
694
+ f"SoM {sku} hw_rev {som_hw_rev!r} is not a known hardware "
695
+ f"revision. Available hw_rev(s) for {sku}: {available}.")
696
+
697
+ known = sdk_compat.revision_known(board_preset, board_hw_rev)
698
+ if known is False:
699
+ available = sorted((board_preset or {}).get("hw_revisions", {}).keys())
700
+ raise SdkRevisionUnknown(
701
+ f"board {board_name} hw_rev {board_hw_rev!r} is not a known "
702
+ f"hardware revision. Available hw_rev(s) for {board_name}: "
703
+ f"{available}.")
704
+
705
+
706
+ def _status_repr(status: Optional[str]) -> str:
707
+ """Render a `status:` value for an error message.
708
+
709
+ `status: None` reads as if the key were literally set to the word
710
+ "None" -- indistinguishable from a typo'd value. A missing key gets
711
+ its own wording instead.
712
+ """
713
+ if status is None:
714
+ return "carries no `status:` key"
715
+ return f"status: {status!r}"
716
+
717
+
718
+ def _check_hw_rev_buildable(
719
+ metadata_root: Path,
720
+ *,
721
+ sku: str,
722
+ som_hw_rev: Optional[str],
723
+ board_name: Optional[str],
724
+ board_hw_rev: Optional[str],
725
+ board_preset: Optional[dict[str, Any]],
726
+ ) -> None:
727
+ """Refuse an hw_rev that EXISTS but whose declared `status:` refuses a
728
+ build (alp-sdk #1025, the maintainer's broad-reading decision on the
729
+ status half).
730
+
731
+ Runs AFTER `_check_hw_rev_exists`: a revision absent from the table is
732
+ that gate's failure to report, not this one's -- there is no status to
733
+ have read. `status: reserved`, `status: tbd`, and a revision carrying
734
+ no `status` key at all are all refused; every other declared status
735
+ (`production`, `preview`, `preliminary`, `deprecated`) passes.
736
+ """
737
+ family_dir = _sku_family_dir(sku)
738
+
739
+ buildable = sdk_compat.family_revision_buildable(metadata_root, family_dir, som_hw_rev)
740
+ if buildable is False:
741
+ status = sdk_compat.family_revision(metadata_root, family_dir, som_hw_rev).get("status")
742
+ raise SdkRevisionNotBuildable(
743
+ f"SoM {sku} hw_rev {som_hw_rev!r} exists but is not buildable "
744
+ f"({_status_repr(status)}).")
745
+
746
+ buildable = sdk_compat.revision_buildable(board_preset, board_hw_rev)
747
+ if buildable is False:
748
+ status = sdk_compat.board_revision(board_preset, board_hw_rev).get("status")
749
+ raise SdkRevisionNotBuildable(
750
+ f"board {board_name} hw_rev {board_hw_rev!r} exists but is not "
751
+ f"buildable ({_status_repr(status)}).")
752
+
753
+
754
+ def _check_sdk_supports_hw_rev(
755
+ metadata_root: Path,
756
+ *,
757
+ sku: str,
758
+ som_hw_rev: Optional[str],
759
+ board_name: Optional[str],
760
+ board_hw_rev: Optional[str],
761
+ board_preset: Optional[dict[str, Any]],
762
+ ) -> None:
763
+ """Refuse when the running SDK is outside a requested revision's range.
764
+
765
+ `metadata/sdk_version.yaml` has always documented this refusal -- and
766
+ the matching `exit code 3` in `scripts/validate_board_yaml.py` -- but
767
+ nothing implemented it, so an upgraded SDK silently emitted for a
768
+ revision it no longer supported (alp-sdk #1019).
769
+
770
+ Callers run `_check_hw_rev_exists` first: an unknown revision is that
771
+ gate's failure, not this one's -- it has no bounds to compare.
772
+ """
773
+ sdk_version = sdk_compat.read_sdk_version(metadata_root)
774
+ if sdk_version is None:
775
+ return
776
+
777
+ family_dir = _sku_family_dir(sku)
778
+
779
+ reason = sdk_compat.check(
780
+ sdk_version,
781
+ som_revision=sdk_compat.family_revision(
782
+ metadata_root, family_dir, som_hw_rev),
783
+ som_label=f"SoM {sku} hw_rev {som_hw_rev}",
784
+ board_revision_entry=sdk_compat.board_revision(
785
+ board_preset, board_hw_rev),
786
+ board_label=f"board {board_name} hw_rev {board_hw_rev}",
787
+ )
788
+ if reason:
789
+ raise SdkRevisionUnsupported(
790
+ f"SDK {sdk_version} does not support this hardware revision: "
791
+ f"{reason}. Pin an SDK inside the declared range, or set a "
792
+ f"hw_rev this SDK supports.")
793
+
794
+
795
+ def _refuse_on_schema_errors(
796
+ doc: dict[str, Any], schema_path: Path, source: Path, schema_name: str, subject: str,
797
+ *, skip_advisories: list[str] | None = None,
798
+ ) -> None:
799
+ """tan-cli#964: refuse a `board.yaml`-bound `tan build`/`tan generate` run
800
+ when the SoM preset or SoC spec it just read does not validate against
801
+ its own schema.
802
+
803
+ This is the REFUSE half of #964's decided rule: `load_board_yaml` is what
804
+ `tan build` and `tan generate` (via `planner_root.emit` /
805
+ `planner_emit.render`) call to turn a `board.yaml` into a plan or an
806
+ emitted file, and every `OrchestratorError` raised anywhere in that walk
807
+ already becomes a coded refusal at the command layer (`build.plan-
808
+ unavailable` / `generate.emit-failed`) rather than a traceback -- so
809
+ raising here, at the point the document is READ, closes the read-path gap
810
+ #964 is about with the SAME refusal machinery every other loader check in
811
+ this file already uses (`_check_hw_rev_exists` and friends), not a new
812
+ one. `tan.core.metadata_schema.validate_document` never raises itself --
813
+ a CORRUPT schema file degrades to one synthetic message (which DOES
814
+ refuse, below, exactly like a real violation); an ABSENT schema file
815
+ degrades to `[]`, no message at all, which must never refuse (see
816
+ `validate_document`'s own docstring) -- the refusal, and its exit
817
+ code/issue code, are entirely this function's unconditional-if-non-empty
818
+ read of the returned list.
819
+
820
+ *skip_advisories* (tan-cli#964 review, major 6): when given, collects
821
+ `tan.core.metadata_schema.missing_schema_note`'s disclosure for the
822
+ ABSENT-schema case -- "skip-but-disclose", not a silent skip. Checked
823
+ unconditionally, before the refuse-worthy violations: a missing schema
824
+ means `validate_document` returns `[]` and this function would otherwise
825
+ return having said nothing at all, which is the exact vacuous-gate shape
826
+ the review measured (`tan generate --target os-topology` writing an
827
+ unvalidated SoC spec to disk at `ok: true`, `issues: []`).
828
+ """
829
+ if skip_advisories is not None:
830
+ note = missing_schema_note(schema_path, source=source)
831
+ if note is not None:
832
+ skip_advisories.append(note)
833
+ errors = validate_document(doc, schema_path, source)
834
+ if not errors:
835
+ return
836
+ raise OrchestratorError(
837
+ f"{subject} does not validate against {schema_name}:\n" +
838
+ "\n".join(f" - {e}" for e in errors))
839
+
840
+
841
+ def _resolve_board_impl(
842
+ project: dict[str, Any],
843
+ metadata_root: Path,
844
+ *, skip_advisories: list[str] | None = None,
845
+ ) -> tuple[str, Optional[str], dict[str, Any], str, dict[str, Any],
846
+ dict[str, Any], Optional[str], Optional[str]]:
847
+ sku = project["som"]["sku"]
848
+ hw_rev = project["som"].get("hw_rev")
849
+
850
+ # Resolve SKU preset.
851
+ sku_preset_path = metadata_root / "e1m_modules" / f"{sku}.yaml"
852
+ if not sku_preset_path.is_file():
853
+ raise OrchestratorError(
854
+ f"no preset for SoM SKU {sku} at "
855
+ f"{sku_preset_path.relative_to(REPO) if sku_preset_path.is_relative_to(REPO) else sku_preset_path}")
856
+ som_preset = _load_yaml(sku_preset_path)
857
+ _refuse_on_schema_errors(
858
+ som_preset, som_preset_schema_path(metadata_root), sku_preset_path,
859
+ "som-preset-v2", f"SoM preset {sku}", skip_advisories=skip_advisories)
860
+
861
+ # Resolve SoC spec via the preset's `silicon:` ref.
862
+ silicon = som_preset.get("silicon")
863
+ if not silicon:
864
+ raise OrchestratorError(
865
+ f"SoM preset {sku} has no `silicon:` field")
866
+ soc_path = _silicon_to_soc_path(silicon, metadata_root)
867
+ if not soc_path.is_file():
868
+ raise OrchestratorError(
869
+ f"no SoC spec at {soc_path.relative_to(REPO) if soc_path.is_relative_to(REPO) else soc_path} for ref '{silicon}'")
870
+ soc_spec = _load_json(soc_path)
871
+ _refuse_on_schema_errors(
872
+ soc_spec, soc_spec_schema_path(metadata_root), soc_path,
873
+ "soc-spec-v1", f"SoC spec for {silicon}", skip_advisories=skip_advisories)
874
+
875
+ # Board definition. Two mutually-exclusive sources (the
876
+ # schema's `oneOf` rule enforces this):
877
+ # - `preset: <name>` -> load metadata/boards/<name>.yaml
878
+ # - inline `name:` + `populated:` + `e1m_routes:` at top level
879
+ # Either way the rest of the loader sees a single board_preset
880
+ # dict with `name`, `populated`, `e1m_routes`.
881
+ if "preset" in project:
882
+ board_preset = _resolve_board_preset(project["preset"], metadata_root)
883
+ _check_board_hosts_som_family(
884
+ sku, som_preset, project["preset"], board_preset)
885
+ else:
886
+ board_preset = _synthesize_inline_board(project)
887
+ board_name = board_preset.get("name")
888
+ board_hw_rev = (project.get("hw_rev")
889
+ or board_preset.get("default_hw_rev"))
890
+
891
+ return (sku, hw_rev, som_preset, silicon, soc_spec, board_preset,
892
+ board_name, board_hw_rev)
893
+
894
+
895
+ def _validate_topology_cores(
896
+ project: dict[str, Any],
897
+ som_preset: dict[str, Any],
898
+ soc_spec: dict[str, Any],
899
+ sku: str,
900
+ silicon: str,
901
+ board_preset: dict[str, Any],
902
+ board_name: Optional[str],
903
+ metadata_root: Path,
904
+ ) -> tuple[dict[str, Slice], list[IpcEntry]]:
905
+ """Stage 3 of the #673 Phase-1 `load_board_yaml` split: per-core
906
+ topology resolution + OS/class enforcement, IPC endpoint
907
+ cross-checks, and the optional top-level `pins:` cross-check
908
+ against the resolved board's `e1m_routes:`.
909
+
910
+ Returns (cores, ipc_entries).
911
+ """
912
+ # Compute per-core effective mapping.
913
+ project_cores = project.get("cores") or {}
914
+ som_topology = som_preset.get("topology") or {}
915
+ soc_core_ids = [c["id"] for c in (soc_spec.get("cores") or []) if "id" in c]
916
+
917
+ # Reject SoM topology keys that don't exist in the SoC spec --
918
+ # surfaces preset bugs early.
919
+ bad_topology = [k for k in som_topology.keys() if k not in soc_core_ids]
920
+ if bad_topology:
921
+ raise OrchestratorError(
922
+ f"SoM preset {sku} `topology:` references core IDs "
923
+ f"{bad_topology} that aren't in SoC {silicon}'s "
924
+ f"cores[] (known: {soc_core_ids})")
925
+
926
+ # Phase B gap fix G-4 (hardened by #603): catch the cross-class
927
+ # `som.sku:` swap where `cores.<key>` doesn't match this SoM preset's
928
+ # `topology:`. Example: customer has `cores.m55_hp:` and swaps
929
+ # som.sku from E1M-AEN801 (topology: m55_hp + m55_he + a32_cluster)
930
+ # to E1M-V2N101 (topology: m33_sm + a55_cluster). Pre-fix the slice-
931
+ # build loop iterated topology keys, NOT project_cores keys, so
932
+ # `cores.m55_hp:` was silently dropped and the customer got an
933
+ # empty slice with no diagnostic.
934
+ #
935
+ # #603: EVERY unmatched key under `cores:` is a hard error, not just
936
+ # the all-unmatched case -- a `cores:` mapping with one valid core
937
+ # and one typo used to only warn-and-drop the typo, so a misspelled
938
+ # core silently vanished from the build while the file still
939
+ # validated "clean". There is no compatibility policy that
940
+ # tolerates an unknown core key, so this is unconditional. The
941
+ # SoC-level mismatch check (topology is a subset of SoC core IDs
942
+ # per the bad_topology guard above) is subsumed by this topology-
943
+ # level check: anything not in topology is wrong from the
944
+ # customer's POV, whether it's SoC-absent or merely SoM-preset-
945
+ # absent.
946
+ topology_keys = set(som_topology.keys())
947
+ project_keys = set(project_cores.keys())
948
+ unmatched = sorted(project_keys - topology_keys)
949
+ if unmatched:
950
+ sku_topology = sorted(topology_keys)
951
+ raise OrchestratorError(
952
+ f"board.yaml `cores:` declares unknown core id(s) {unmatched} "
953
+ f"that {sku}'s `topology:` does not expose. "
954
+ f"Did you mean one of: {sku_topology}?")
955
+
956
+ # Index SoC cores[] by id so we can look up `type` for the OS
957
+ # default inference (Finding A: pre-2026-05-18 every SoM YAML's
958
+ # `topology.<core>.os` followed cortex-m* -> zephyr, cortex-a* ->
959
+ # yocto; the loader now infers that fallback when `os:` is absent).
960
+ soc_core_type_by_id: dict[str, str] = {
961
+ str(c["id"]): str(c.get("type") or "")
962
+ for c in (soc_spec.get("cores") or []) if "id" in c
963
+ }
964
+
965
+ # SoC-variant `debug:` facts, resolved from ONE variant match (see
966
+ # `_resolve_variant_debug`). `jlink_flash_device` is not per-core, so
967
+ # it is resolved once and reused for every slice below; the
968
+ # `expect_dpidr`/`jlink_device` preflight pair IS per-core (the read
969
+ # attach profile is keyed by `cores[].id`) and is resolved inside the
970
+ # loop.
971
+ variant_debug = _resolve_variant_debug(som_preset, soc_spec)
972
+ jlink_flash_device = _resolve_jlink_flash_device(variant_debug)
973
+ # tan-cli#734: carried alongside the value, not derived from it -- the
974
+ # two differ exactly in the declared-null case this exists for.
975
+ jlink_flash_device_declared = _jlink_flash_device_declared(variant_debug)
976
+
977
+ cores: dict[str, Slice] = {}
978
+ for core_id in soc_core_ids:
979
+ resolved = _resolve_topology_for_core(
980
+ core_id, project_cores, som_topology)
981
+ if resolved is None:
982
+ # Multi-core SoCs require either project cores: or topology
983
+ # preset coverage; single-core SoCs default the one core to
984
+ # the preset (which we already checked above -- if we got
985
+ # here for a single-core SoC, the preset has no topology
986
+ # entry for the only core, which is a real error).
987
+ raise OrchestratorError(
988
+ f"core '{core_id}' has no runtime assigned (neither "
989
+ f"board.yaml `cores.{core_id}` nor SoM preset "
990
+ f"`topology.{core_id}` is set)")
991
+ expect_dpidr, jlink_device = _resolve_flow_d_preflight(
992
+ variant_debug, core_id)
993
+ # PRESENCE, not value -- the same promotion the `flash_args` emit
994
+ # uses, because alp-sdk#1444/#1446 (`86260edc`, "complete the Flow D
995
+ # truthiness fix") moved this gate to presence upstream.
996
+ #
997
+ # It used to be VALUE here, deliberately: tan-cli#744 matched
998
+ # alp-sdk's own gate of the day, and tan-cli#737's presence
999
+ # promotion had to be reverted because it emitted a
1000
+ # `slot0_load_address` alp-sdk did not. That rationale expired when
1001
+ # upstream flipped -- the byte-parity suite then caught the SAME
1002
+ # example from the other side: `usb-host-storage` (E1M-AEN401)
1003
+ # `--emit system-manifest` line 19, sdk emitting
1004
+ # `slot0_load_address: '0x802b0000'` where tan emitted nothing
1005
+ # (tan-cli#756, measured against alp-sdk 88318e75 on all three OSes).
1006
+ #
1007
+ # Presence is also the coherent half of the pair: a declared
1008
+ # `jlink_flash_device: null` (e4.json is the live case) reaches
1009
+ # `flash_args` AS a present null so the consumer's presence check
1010
+ # (`flash_plan._fa_has_key`) refuses loudly, and the slot0 window a
1011
+ # Flow D write would target travels with it instead of vanishing.
1012
+ # An ABSENT key still resolves nothing, on both gates.
1013
+ slot0_load_address = (
1014
+ _resolve_slot0_load_address(som_preset, core_id)
1015
+ if (jlink_flash_device_declared or jlink_flash_device is not None)
1016
+ else None)
1017
+ slice_ = _slice_from_resolved(
1018
+ core_id, resolved,
1019
+ soc_core_type=soc_core_type_by_id.get(core_id, ""),
1020
+ jlink_flash_device=jlink_flash_device,
1021
+ jlink_flash_device_declared=jlink_flash_device_declared,
1022
+ expect_dpidr=expect_dpidr,
1023
+ jlink_device=jlink_device,
1024
+ slot0_load_address=slot0_load_address,
1025
+ )
1026
+ _enforce_flow_d_preflight_pair(slice_, variant_debug, sku)
1027
+ _enforce_loader_rules(slice_, metadata_root)
1028
+ _enforce_os_matches_core_class(
1029
+ slice_, soc_core_type_by_id.get(core_id, ""))
1030
+ cores[core_id] = slice_
1031
+
1032
+ _enforce_slot0_disjoint_across_roles(cores, sku)
1033
+
1034
+ # IPC entries.
1035
+ ipc_raw = project.get("ipc") or []
1036
+ ipc_entries: list[IpcEntry] = []
1037
+ for entry in ipc_raw:
1038
+ ipc_entries.append(IpcEntry(
1039
+ name=entry["name"],
1040
+ kind=entry["kind"],
1041
+ endpoints=list(entry["endpoints"]),
1042
+ carve_out_kb=int(entry["carve_out_kb"]),
1043
+ cacheable=entry.get("cacheable"),
1044
+ address=entry.get("address"),
1045
+ ))
1046
+
1047
+ # alp-sdk #1088: `kind: rpmsg` has no cache-maintenance layer.
1048
+ # `cfg->cacheable` is stored on the backend struct
1049
+ # (`src/backends/rpc/{zephyr,yocto}_drv.c`) and never read again -- no
1050
+ # `sys_cache_*` call exists anywhere under `src/` or `include/`.
1051
+ # `cacheable: true` on a rpmsg entry would therefore select a code path
1052
+ # that promises coherency it can't deliver, which is worse than no flag
1053
+ # at all -- reject it here rather than silently honouring it. Real fix
1054
+ # (sys_cache_data_flush_range / sys_cache_data_invd_range in
1055
+ # <alp/rpc.h>) remains open; see alp-sdk #1088.
1056
+ for e in ipc_entries:
1057
+ if e.kind == "rpmsg" and e.cacheable:
1058
+ raise OrchestratorError(
1059
+ f"ipc entry '{e.name}': kind: rpmsg does not support "
1060
+ f"cacheable: true -- <alp/rpc.h> has no cache-maintenance "
1061
+ f"implementation yet (#1088). The D-cache is forced off "
1062
+ f"for this carve-out's endpoints instead; remove "
1063
+ f"`cacheable: true` (or set it to false).")
1064
+
1065
+ # Loader rule §4.5.6: every ipc endpoint must be a core with
1066
+ # os != off.
1067
+ for e in ipc_entries:
1068
+ for ep in e.endpoints:
1069
+ if ep not in cores:
1070
+ raise OrchestratorError(
1071
+ f"ipc entry '{e.name}' references core '{ep}' "
1072
+ f"that isn't in this project")
1073
+ if cores[ep].os == "off":
1074
+ raise OrchestratorError(
1075
+ f"ipc entry '{e.name}' references core '{ep}' "
1076
+ f"which is os: off")
1077
+
1078
+ # Optional top-level `pins:` cross-check. When the project
1079
+ # lists which E1M pads it actively uses, every entry must exist
1080
+ # in the resolved board's `e1m_routes:` block; entries that
1081
+ # supply a `macro:` must also match the board's macro for that
1082
+ # pad. Catches typos + demos drifting from the EVK preset's
1083
+ # wiring. Each entry is either a bare string (just the pad
1084
+ # name) or a `{e1m, macro?, doc?}` mapping.
1085
+ used_pins = list(project.get("pins") or [])
1086
+ if used_pins:
1087
+ routes = (board_preset or {}).get("e1m_routes") or {}
1088
+ # Build a {pad -> [macros]} index; one pad can have several
1089
+ # macros aliasing it (e.g. E1M_PWM1 maps to EVK_PWM_LED_BLUE
1090
+ # AND EVK_ARD_PWM1 on the EVK).
1091
+ macros_by_pad: dict[str, set[str]] = {}
1092
+ for section in ("gpio", "buses", "pwm", "adc", "dac", "i2s", "can", "qenc"):
1093
+ for entry in (routes.get(section) or []):
1094
+ e1m = entry.get("e1m")
1095
+ macro = entry.get("macro")
1096
+ if isinstance(e1m, str) and isinstance(macro, str):
1097
+ macros_by_pad.setdefault(e1m, set()).add(macro)
1098
+ board_label = board_name or "<inline>"
1099
+ for idx, item in enumerate(used_pins):
1100
+ if isinstance(item, str):
1101
+ e1m_pad, macro_decl = item, None
1102
+ elif isinstance(item, dict):
1103
+ e1m_pad = item.get("e1m")
1104
+ macro_decl = item.get("macro")
1105
+ else:
1106
+ raise OrchestratorError(
1107
+ f"board.yaml `pins[{idx}]` is neither a string nor a mapping")
1108
+ if e1m_pad not in macros_by_pad:
1109
+ raise OrchestratorError(
1110
+ f"board.yaml `pins[{idx}].e1m: {e1m_pad}` is not in the "
1111
+ f"resolved board '{board_label}'s `e1m_routes:` block. "
1112
+ f"Known pads: {sorted(macros_by_pad.keys())}")
1113
+ if macro_decl is not None and macro_decl not in macros_by_pad[e1m_pad]:
1114
+ raise OrchestratorError(
1115
+ f"board.yaml `pins[{idx}].macro: {macro_decl}` does not "
1116
+ f"match the resolved board '{board_label}'s macros for "
1117
+ f"pad {e1m_pad}: {sorted(macros_by_pad[e1m_pad])}")
1118
+
1119
+ return cores, ipc_entries
1120
+
1121
+
1122
+ def _resolve_storage(
1123
+ project: dict[str, Any],
1124
+ som_preset: dict[str, Any],
1125
+ sku: str,
1126
+ metadata_root: Path,
1127
+ ) -> list[StorageEntry]:
1128
+ """Stage 4 of the #673 Phase-1 `load_board_yaml` split: storage
1129
+ partitions (board.yaml `storage:` block). Parse into StorageEntry
1130
+ dataclasses + cross-field check: every `flash_device:` must resolve
1131
+ to either a memory_map region name or an `on_module.ospi_memories:`
1132
+ key. Resolution of base addresses / overlap detection happens in
1133
+ `resolve_storage_partitions()`; the loader catches typos eagerly
1134
+ because they are cheap to surface before the build kicks off.
1135
+ """
1136
+ storage_raw = project.get("storage") or []
1137
+ storage_entries: list[StorageEntry] = []
1138
+ for idx, item in enumerate(storage_raw):
1139
+ # `fs` defaults to `raw` when omitted. The legacy `raw: true` alias is
1140
+ # gone: `board.schema.json` no longer declares the property and sets
1141
+ # `additionalProperties: false` on storage items, so a board carrying it
1142
+ # is rejected at validation rather than normalised here. Measured before
1143
+ # removal: zero tracked `board.yaml` files used it.
1144
+ fs = item.get("fs")
1145
+ if fs is None:
1146
+ fs = "raw"
1147
+ storage_entries.append(StorageEntry(
1148
+ name=item["name"],
1149
+ size_kib=int(item["size_kib"]),
1150
+ fs=fs,
1151
+ mount=item.get("mount"),
1152
+ flash_device=item.get("flash_device"),
1153
+ offset_kib=(int(item["offset_kib"])
1154
+ if item.get("offset_kib") is not None else None),
1155
+ ))
1156
+
1157
+ # Cross-field: known flash device set is memory_map names + ospi keys.
1158
+ if storage_entries:
1159
+ known_devices = set(_known_flash_devices(som_preset, metadata_root))
1160
+ for entry in storage_entries:
1161
+ if entry.flash_device is None:
1162
+ continue # resolver will block it with a clear reason
1163
+ if _is_ospi_key_unassembled(som_preset, entry.flash_device):
1164
+ # Same #2311 guard as the PSA ITS/PS backing-store check
1165
+ # below: an `ospi_memories` key with `assembled: false`
1166
+ # (e.g. E1M-AEN801's `ospi0`/`ospi1`) is a designed-in
1167
+ # footprint, not a part this SKU actually carries -- refuse
1168
+ # it as a `storage[].flash_device` with the specific reason
1169
+ # rather than the generic "does not resolve" message.
1170
+ where = f"SoM {sku}" if sku else "this SoM"
1171
+ yaml_ref = (f"metadata/e1m_modules/{sku}.yaml" if sku
1172
+ else "its SoM preset YAML")
1173
+ raise OrchestratorError(
1174
+ f"board.yaml `storage[{entry.name}].flash_device: "
1175
+ f"{entry.flash_device}` names on-module OSPI part "
1176
+ f"'{entry.flash_device}', which is not assembled on "
1177
+ f"{where} (assembled: false in {yaml_ref}); pick a "
1178
+ f"flash device backed by a part this SKU actually "
1179
+ f"carries")
1180
+ if entry.flash_device not in known_devices:
1181
+ raise OrchestratorError(
1182
+ f"board.yaml `storage[{entry.name}].flash_device: "
1183
+ f"{entry.flash_device}` does not resolve to any "
1184
+ f"flash device on SoM {sku}. Known devices: "
1185
+ f"{sorted(known_devices)}")
1186
+ # Name uniqueness within storage[].
1187
+ names_seen: set[str] = set()
1188
+ for entry in storage_entries:
1189
+ if entry.name in names_seen:
1190
+ raise OrchestratorError(
1191
+ f"board.yaml `storage:` declares partition "
1192
+ f"`{entry.name}` more than once; names must be "
1193
+ f"unique within the project")
1194
+ names_seen.add(entry.name)
1195
+
1196
+ return storage_entries
1197
+
1198
+
1199
+ def _validate_cross_fields(
1200
+ project: dict[str, Any],
1201
+ som_preset: dict[str, Any],
1202
+ sku: str,
1203
+ storage_entries: list[StorageEntry],
1204
+ metadata_root: Path,
1205
+ ) -> dict[str, Any]:
1206
+ """Stage 5 of the #673 Phase-1 `load_board_yaml` split:
1207
+ `security.psa:` cross-field validation. The schema is
1208
+ authoritative on field types; this block enforces the references:
1209
+ ITS/PS storage names must resolve to a `storage[].name`, a SoM
1210
+ memory_map region name, OR an `on_module.ospi_memories:` key
1211
+ (PS-class storage often lives on an on-module OSPI part rather
1212
+ than in MRAM); `attestation_root: optiga_trust_m` requires the
1213
+ SoM to physically ship OPTIGA Trust M. Errors point at the
1214
+ offending board.yaml path so the customer can fix it.
1215
+
1216
+ Returns the raw `security:` block for BoardProject assembly.
1217
+ """
1218
+ security_block = dict(project.get("security") or {})
1219
+ psa = dict(security_block.get("psa") or {})
1220
+ if psa:
1221
+ storage_name_set = {e.name for e in storage_entries}
1222
+ try:
1223
+ mem_map = resolve_memory_map(som_preset, metadata_root)
1224
+ except Exception: # noqa: BLE001
1225
+ mem_map = []
1226
+ region_names = {
1227
+ str(r.get("name")) for r in mem_map
1228
+ if isinstance(r, dict) and r.get("name")
1229
+ }
1230
+ ospi_keys = {
1231
+ str(k) for k in
1232
+ ((som_preset.get("on_module") or {}).get("ospi_memories") or {}).keys()
1233
+ if isinstance(k, str)
1234
+ }
1235
+ valid_refs = storage_name_set | region_names | ospi_keys
1236
+
1237
+ def _check_backing_store(field: str) -> None:
1238
+ ref = psa.get(field)
1239
+ if ref is None:
1240
+ return
1241
+ ref_str = str(ref)
1242
+ if ref_str in ospi_keys and _is_ospi_key_unassembled(
1243
+ som_preset, ref_str):
1244
+ # Same #2311 guard as `_known_flash_devices()` /
1245
+ # `_resolve_flash_device()` in partition.py: an
1246
+ # `ospi_memories` key with `assembled: false` (e.g.
1247
+ # E1M-AEN801's `ospi0`/`ospi1`) is a designed-in footprint,
1248
+ # not a part this SKU actually carries -- refuse it as a
1249
+ # PSA ITS/PS backing store with the specific reason rather
1250
+ # than the generic "does not resolve" message below.
1251
+ sku = som_preset.get("sku")
1252
+ where = f"SoM {sku}" if sku else "this SoM"
1253
+ yaml_ref = (f"metadata/e1m_modules/{sku}.yaml" if sku
1254
+ else "its SoM preset YAML")
1255
+ raise OrchestratorError(
1256
+ f"board.yaml `security.psa.{field}: {ref}` names "
1257
+ f"on-module OSPI part '{ref_str}', which is not "
1258
+ f"assembled on {where} (assembled: false in "
1259
+ f"{yaml_ref}); pick a storage partition or memory "
1260
+ f"region backed by a part this SKU actually carries")
1261
+ if ref_str in valid_refs:
1262
+ return
1263
+ raise OrchestratorError(
1264
+ f"board.yaml `security.psa.{field}: {ref}` does not "
1265
+ f"resolve to any `storage[].name`, SoM "
1266
+ f"`memory_map[].name`, or `on_module.ospi_memories:` "
1267
+ f"key. Known storage partitions: "
1268
+ f"{sorted(storage_name_set) or '[]'}; "
1269
+ f"known SoM memory regions: "
1270
+ f"{sorted(region_names) or '[]'}; "
1271
+ f"known on-module OSPI parts: "
1272
+ f"{sorted(ospi_keys) or '[]'}.")
1273
+
1274
+ _check_backing_store("its_storage")
1275
+ _check_backing_store("ps_storage")
1276
+
1277
+ att_root = psa.get("attestation_root")
1278
+ if att_root == "optiga_trust_m":
1279
+ if _is_i2c_chip_unassembled(som_preset, "optiga_trust_m"):
1280
+ # alp-sdk#2316 (tan-cli#1295), same class as alp-sdk#2311's
1281
+ # OSPI guard: E1M-AEN801/803 carry
1282
+ # `capabilities.optiga_trust_m: true` and name the part
1283
+ # under `on_module:`, but the part itself is DNP
1284
+ # (`assembled: false` on its i2c_devices entry). The
1285
+ # population fact wins -- an attestation root on a part
1286
+ # this SKU does not carry would build and then fail on
1287
+ # silicon.
1288
+ where = f"SoM {sku}" if sku else "this SoM"
1289
+ yaml_ref = (f"metadata/e1m_modules/{sku}.yaml" if sku
1290
+ else "its SoM preset YAML")
1291
+ raise OrchestratorError(
1292
+ f"board.yaml `security.psa.attestation_root: "
1293
+ f"optiga_trust_m` names on-module part "
1294
+ f"'optiga_trust_m', which is not assembled on "
1295
+ f"{where} (assembled: false in {yaml_ref}); pick "
1296
+ f"`tfm_internal` or `none`, or switch to a SKU that "
1297
+ f"carries OPTIGA Trust M")
1298
+ on_module = som_preset.get("on_module") or {}
1299
+ chip_set: set[str] = set()
1300
+ for key, val in on_module.items():
1301
+ if key == "ospi_memories":
1302
+ continue
1303
+ if isinstance(val, str):
1304
+ chip_set.add(val)
1305
+ capabilities = som_preset.get("capabilities") or {}
1306
+ has_optiga = (
1307
+ "optiga_trust_m" in chip_set
1308
+ or bool(capabilities.get("optiga_trust_m"))
1309
+ )
1310
+ if not has_optiga:
1311
+ raise OrchestratorError(
1312
+ f"board.yaml `security.psa.attestation_root: "
1313
+ f"optiga_trust_m` requires the SoM preset to "
1314
+ f"ship OPTIGA Trust M on-module, but SoM SKU "
1315
+ f"{sku} does not list it under `on_module:` or "
1316
+ f"`capabilities:`. Pick `tfm_internal` or "
1317
+ f"`none`, or switch to a SoM that carries OPTIGA "
1318
+ f"(AEN family).")
1319
+
1320
+ return security_block
1321
+
1322
+
1323
+ def _is_i2c_chip_unassembled(som_preset: dict, chip: str) -> bool:
1324
+ """True when the preset declares @p chip under `on_module.i2c_devices`
1325
+ and every such entry is `assembled: false` (alp-sdk#2316).
1326
+
1327
+ A chip the preset never lists there is not "unassembled" -- the other
1328
+ presence checks (on_module:/capabilities:) decide that case -- so this
1329
+ only refuses what the preset explicitly marks DNP.
1330
+ """
1331
+ buses = ((som_preset.get("on_module") or {}).get("i2c_devices")) or {}
1332
+ entries = [dev for bus in buses.values() if isinstance(bus, dict)
1333
+ for dev in (bus.get("devices") or [])
1334
+ if isinstance(dev, dict) and dev.get("chip") == chip]
1335
+ return bool(entries) and all(dev.get("assembled") is False
1336
+ for dev in entries)
1337
+
1338
+
1339
+ def _library_alias_table(metadata_root: Path) -> dict[str, str]:
1340
+ """Legacy per-core `libraries:` token -> canonical manifest name
1341
+ (metadata/library-aliases-v1.json). Empty dict if the table is absent."""
1342
+ path = metadata_root / "library-aliases-v1.json"
1343
+ if not path.is_file():
1344
+ return {}
1345
+ doc = strict_json_loads(path.read_text(encoding="utf-8"), source=path)
1346
+ aliases = doc.get("aliases")
1347
+ return dict(aliases) if isinstance(aliases, dict) else {}
1348
+
1349
+
1350
+ def _normalize_libraries(project: dict[str, Any],
1351
+ metadata_root: Path) -> None:
1352
+ """Fold the unified top-level `libraries:` list into the internal channels
1353
+ the emitters consume (WS6-c #610 §6).
1354
+
1355
+ board.yaml declares every curated library once, at the top level, as a
1356
+ `{name, cores?}` object: project-wide when `cores:` is omitted, core-scoped
1357
+ otherwise (a bare string is accepted as shorthand for a project-wide
1358
+ `{name}`). This rewrites the parsed dict so the rest of the loader stays
1359
+ library-shape-agnostic: project-wide names land in `project['libraries']`
1360
+ and core-scoped names are injected into each `cores[<id>]['libraries']`.
1361
+
1362
+ This is the ONLY library read path -- there is no separate per-core
1363
+ `cores.<id>.libraries:` list to read. A `cores:` entry scoping a library
1364
+ to a core the topology doesn't declare is a hard error: silently dropping
1365
+ it would emit nothing for a library the app author explicitly asked for.
1366
+ """
1367
+ unified = project.get("libraries") or []
1368
+ alias = _library_alias_table(metadata_root)
1369
+ cores_map = project.get("cores") or {}
1370
+ project_wide: list[str] = []
1371
+ per_core: dict[str, list[str]] = {}
1372
+ for entry in unified:
1373
+ if isinstance(entry, str):
1374
+ project_wide.append(alias.get(entry, entry))
1375
+ continue
1376
+ if not isinstance(entry, dict):
1377
+ continue
1378
+ name = entry.get("name")
1379
+ if not name:
1380
+ continue
1381
+ canonical = alias.get(name, name)
1382
+ cores = entry.get("cores")
1383
+ if cores:
1384
+ for cid in cores:
1385
+ if str(cid) not in cores_map:
1386
+ raise OrchestratorError(
1387
+ f"libraries: entry '{canonical}' is scoped to core "
1388
+ f"'{cid}', which is not declared under `cores:`")
1389
+ per_core.setdefault(str(cid), []).append(canonical)
1390
+ else:
1391
+ project_wide.append(canonical)
1392
+ project["libraries"] = project_wide
1393
+ for cid, names in per_core.items():
1394
+ centry = cores_map.get(cid)
1395
+ if not isinstance(centry, dict):
1396
+ continue
1397
+ existing = list(centry.get("libraries") or [])
1398
+ for n in names:
1399
+ if n not in existing:
1400
+ existing.append(n)
1401
+ centry["libraries"] = existing
1402
+
1403
+
1404
+ def load_board_yaml(path: Path, *,
1405
+ metadata_root: Path = METADATA_ROOT,
1406
+ sku: Optional[str] = None,
1407
+ skip_advisories: list[str] | None = None) -> BoardProject:
1408
+ """Load + validate a board.yaml.
1409
+
1410
+ Raises OrchestratorError on any schema / preset / topology error.
1411
+
1412
+ #673 Phase 1: staged into a resolve pipeline. YAML/schema load,
1413
+ board/SKU resolution, topology/core validation, storage resolution,
1414
+ and final cross-field validation each run as their own private
1415
+ helper (`_load_and_validate_yaml`, `_resolve_board`,
1416
+ `_validate_topology_cores`, `_resolve_storage`,
1417
+ `_validate_cross_fields`), invoked in the exact same order as the
1418
+ original monolithic function so error precedence and messages are
1419
+ unchanged.
1420
+
1421
+ *skip_advisories* (tan-cli#964 review, major 6): optional, caller-owned,
1422
+ threaded to `_resolve_board`'s `_refuse_on_schema_errors` calls. `None`
1423
+ (the default) costs nothing extra -- every existing call site is
1424
+ unaffected. A caller that wants "skip-but-disclose" (rather than the bare
1425
+ silent skip an absent schema file used to be) passes its own list and
1426
+ folds the collected notes into its own issues after this returns; see
1427
+ `planner_emit._render_route_table`/`generate_cmd._emit_one_in_process`
1428
+ for the two commands that do.
1429
+ """
1430
+ project = _load_and_validate_yaml(path, metadata_root)
1431
+ if sku:
1432
+ # Per-target SKU (alp-sdk#2597): the SoM facts follow the board
1433
+ # being built, not the single static `som.sku` an example declares.
1434
+ # Safe after schema validation: the override only changes the SKU
1435
+ # string, and every SKU-derived value (_resolve_board's preset + SoC
1436
+ # variant lookup, hw_rev checks, topology, storage) is computed
1437
+ # below from it. Callers pass a SKU taken from
1438
+ # metadata/e1m_modules/.
1439
+ project["som"]["sku"] = sku
1440
+
1441
+ # Fold the unified top-level `libraries:` list into the per-core /
1442
+ # project-wide channels the downstream resolution expects, so topology +
1443
+ # slice building stay library-shape-agnostic.
1444
+ _normalize_libraries(project, metadata_root)
1445
+
1446
+ (sku, hw_rev, som_preset, silicon, soc_spec, board_preset,
1447
+ board_name, board_hw_rev) = _resolve_board(
1448
+ project, metadata_root, skip_advisories=skip_advisories)
1449
+
1450
+ _check_hw_rev_exists(
1451
+ metadata_root,
1452
+ sku=sku,
1453
+ som_hw_rev=hw_rev or som_preset.get("default_hw_rev"),
1454
+ board_name=board_name,
1455
+ board_hw_rev=board_hw_rev,
1456
+ board_preset=board_preset)
1457
+
1458
+ _check_hw_rev_buildable(
1459
+ metadata_root,
1460
+ sku=sku,
1461
+ som_hw_rev=hw_rev or som_preset.get("default_hw_rev"),
1462
+ board_name=board_name,
1463
+ board_hw_rev=board_hw_rev,
1464
+ board_preset=board_preset)
1465
+
1466
+ _check_sdk_supports_hw_rev(
1467
+ metadata_root,
1468
+ sku=sku,
1469
+ som_hw_rev=hw_rev or som_preset.get("default_hw_rev"),
1470
+ board_name=board_name,
1471
+ board_hw_rev=board_hw_rev,
1472
+ board_preset=board_preset)
1473
+
1474
+ cores, ipc_entries = _validate_topology_cores(
1475
+ project, som_preset, soc_spec, sku, silicon, board_preset,
1476
+ board_name, metadata_root)
1477
+
1478
+ storage_entries = _resolve_storage(project, som_preset, sku, metadata_root)
1479
+
1480
+ security_block = _validate_cross_fields(
1481
+ project, som_preset, sku, storage_entries, metadata_root)
1482
+
1483
+ ownership = resolve_ownership(
1484
+ load_ownership_doc(metadata_root, _sku_family_dir(sku)),
1485
+ project.get("ownership"),
1486
+ declared_core_types=(
1487
+ {str(c.get("type") or "") for c in (soc_spec.get("cores") or [])
1488
+ if c.get("id") in project["cores"]}
1489
+ if project.get("cores") else None))
1490
+
1491
+ out = BoardProject(
1492
+ sku=sku,
1493
+ hw_rev=hw_rev or som_preset.get("default_hw_rev"),
1494
+ board_name=board_name,
1495
+ board_hw_rev=board_hw_rev,
1496
+ cores=cores,
1497
+ ipc=ipc_entries,
1498
+ soc_spec=soc_spec,
1499
+ som_preset=som_preset,
1500
+ board_preset=board_preset,
1501
+ diagnostics=dict(project.get("diagnostics") or {}),
1502
+ chips=list(project.get("chips") or []),
1503
+ libraries=list(project.get("libraries") or []),
1504
+ features=dict(project.get("features") or {}),
1505
+ boot=dict(project.get("boot") or {}),
1506
+ ota=dict(project.get("ota") or {}),
1507
+ storage=storage_entries,
1508
+ security=security_block,
1509
+ ownership=ownership,
1510
+ raw=project,
1511
+ metadata_root=metadata_root, # tan-cli#573: the tree THIS load read
1512
+ source_dir=Path(path).resolve().parent,
1513
+ )
1514
+
1515
+ # Cross-field consistency pass (v0.6 P2.3). Runs last so it can
1516
+ # inspect the fully-assembled project + every per-core
1517
+ # extra_libraries: entry the schema couldn't validate cleanly.
1518
+ _validate_consistency(out)
1519
+
1520
+ # `diagnostics.link: itcm` is an AEN M55-HE-only knob (tan-cli#1350):
1521
+ # refuse it here so every planner consumer sees the coded error.
1522
+ from .link_target import apply_link_target # noqa: PLC0415
1523
+ apply_link_target(out)
1524
+
1525
+ return out