tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1123 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan presets` -- what the New Project wizard is allowed to offer: the SoM
3
+ presets discovered in the resolved alp-sdk checkout, plus tan's own built-in
4
+ defaults.
5
+
6
+ Mirrors `crates/tan-cli/src/commands/presets.rs` and the three `tan-core`
7
+ helpers it composes (`presets::empty_preset_catalogue`,
8
+ `sdk_catalogue::parse_som_preset`, `bootstrap::runtime_for_topology_core`). One
9
+ module, deliberately: the whole command is two directory reads, a small YAML
10
+ read, and four constant lists.
11
+
12
+ **The customer never chooses an OS.** `data.soms[].cores[].os` is DERIVED, per
13
+ core, from the SoM's own topology in the SDK metadata -- a topology entry with a
14
+ `board:` is a Zephyr (Cortex-M) target, one with a `machine:` is a Yocto
15
+ (Cortex-A) one, and failing both the core-id class decides (`a<digit>` at a word
16
+ start -> yocto, else zephyr). It is a REPORT of what the silicon runs, which is
17
+ why there is no `--os` and no `--backend` flag here and why `osChoices` is a
18
+ built-in vocabulary list rather than a menu bound to any SoM. The heterogeneous
19
+ golden (`a55` -> yocto next to `m33` -> zephyr) is the case that proves it: both
20
+ values come back on one SoM, so a consumer cannot read the field as a choice.
21
+
22
+ **`cores[].type` and `cores[].allowedOs` (tan-cli#870).** The default `os` above
23
+ answers "what does this core run today"; a picker also needs "what MAY this core
24
+ run" -- and re-deriving that from the core id with a regex is the duplicated-truth
25
+ defect #870 exists to close: `alp-sdk-vscode@dev`'s
26
+ `packages/alp-webview/src/shared/coreRuntime.ts` re-derives the cortex-a/cortex-m
27
+ class from the core id with its own `/(^|[_-])m\\d/` heuristic, independently of
28
+ whatever this checkout's own SoC metadata says, and two independently-maintained
29
+ copies of the same rule are one edit away from disagreeing. (`alp-sdk-vscode#538`
30
+ itself was a different bug in the same wizard -- Bare-metal was offered for
31
+ every Cortex-M core and the scaffolder wrote it a Zephyr app anyway, which
32
+ `alp_project.py` then refused because `os: baremetal` needs `cmake-args`, not
33
+ because a Cortex-M was offered Bare-metal -- so these fields would not, by
34
+ themselves, have prevented #538; the duplicated-truth argument above is the
35
+ motivation that stands.) Both fields are read from the checkout, never invented here:
36
+ `type` is the raw `metadata/socs/<vendor>/<family>/<part>.json` `cores[].type`
37
+ string (`cortex-a32`, `cortex-m55`, ...), resolved via the SoM preset's own
38
+ `silicon:` key; `allowedOs` is that type's cross-class OS subtracted from the
39
+ checkout's own `board.schema.json` `os:` enum, using `tan.core.os_class` --
40
+ the SAME cortex-a/cortex-m convention
41
+ `tan.planner.validate._enforce_os_matches_core_class` gates a build on --
42
+ reused rather than re-implemented so the wizard's picker and the build-time
43
+ gate can never disagree on the cross-class exclusion itself (a Cortex-A is
44
+ never offered, and never accepted for, Zephyr; a Cortex-M is never offered,
45
+ and never accepted for, Yocto -- both read `tan.core.os_class.cross_class_os`).
46
+ That is narrower than "can never disagree" full stop: for an UNRESOLVED core
47
+ type (`core_type == ""`), `allowedOs` here degrades all the way to `[]`
48
+ (tan-cli#914's `allowed_os_for_core` guard), while `_enforce_os_matches_core_class`
49
+ does not share that guard -- it calls `cross_class_os` directly, which treats
50
+ `""` as just another off-class type and still accepts `os: baremetal` /
51
+ `os: off` for it. Deliberately narrower in presets than in the gate: the
52
+ wizard offers nothing rather than guess, the gate still allows the two
53
+ values every core, resolved or not, may always take (see `_soc_lookups`'s own
54
+ docstring for why this reads `tan.core.os_class` and not `tan.planner.topology`
55
+ directly: the latter's process-global SDK-root binding is the wrong contract
56
+ for a command exercised by dozens of independent per-test checkouts).
57
+ `allowedOs` degrades to `[]` on a schema miss; `type` degrades to `""` -- and
58
+ drags `allowedOs` to `[]` -- on a SoC-lookup miss (no `metadata/socs/**`
59
+ entry for the SoM's `silicon:`), and identically on a SoC-lookup HIT whose
60
+ `type` value is not itself a JSON string (tan-cli#957 -- a schema-invalid
61
+ SoC JSON `soc-spec-v1.schema.json` forbids but `tan` does not itself
62
+ validate against before reading). Neither miss nor malformed value fails the
63
+ command (a synthetic/partial/invalid `--sdk-root` is tolerated elsewhere) --
64
+ no SoM detail here is load-bearing enough to fail `tan presets` over.
65
+
66
+ **`allowedOs: []` alone does not say which of the two above degrades fired --
67
+ `type` does.** Two different unknowns both wire as an empty `allowedOs`
68
+ array with `ok: true` and no issue code: a SoC-lookup miss (no
69
+ `metadata/socs/**` entry, or no entry for this core id -- `type` is ALSO
70
+ `""`), and a schema miss (`type` resolves fine, but the checkout carries no
71
+ `board.schema.json` -- `_os_choices()` returns `None`, `allowed_os_lookup`
72
+ returns `[]` before ever reaching the unresolved-type guard in
73
+ `allowed_os_for_core`). A consumer that needs to tell them apart reads
74
+ `type` alongside `allowedOs`, never `allowedOs` alone: `type == ""` means
75
+ the core's type itself is unresolved; a non-empty `type` with `allowedOs ==
76
+ []` means the type resolved but this checkout has no schema to validate an
77
+ `os:` value against. Neither shape means "no OS is legal" -- that reading of
78
+ an empty array does not exist on this wire.
79
+
80
+ **Which rule decides where the presets come from.** SoM presets are alp-sdk
81
+ content, so the two candidate readings are "vendor a capture like `tan init`
82
+ does" and "read the resolved checkout". This reads the checkout, on two
83
+ invariants from
84
+ `alp-sdk/docs/superpowers/specs/2026-07-29-tan-port-invariants.md`:
85
+
86
+ * **I-32** bans *shelling* the SDK -- `tan init`/`tan scaffold` read a vendored
87
+ `--emit scaffold` tree "without ever shelling the SDK" because they must work
88
+ with NO checkout present. Nothing here spawns a subprocess (there is no
89
+ subprocess to time out); a `scandir` over `<sdk>/metadata/e1m_modules` is not a
90
+ shell-out, so I-32 is untouched. Anti-pattern #22 in that doc is *shelling
91
+ `init`* instead of reading the vendored tree -- not "reading a checkout".
92
+ * **I-26** ("tan must never learn a hardware fact ... `metadata/**` stays in
93
+ alp-sdk") forbids the vendored alternative HERE, and forbids it hardest: a
94
+ baked SKU list is precisely the hardware fact tan may not hold. Every SKU,
95
+ family, core id and runtime below is read from the checkout at runtime; this
96
+ file names no SKU, no address, no pin and no vendor. With no checkout
97
+ resolvable the SDK-derived lists are legitimately EMPTY plus a warning, which
98
+ is what the oracle does -- so it has no reason to carry a capture.
99
+
100
+ **An unresolved SDK is exit 0 with a warning, not a failure.** The built-in
101
+ defaults are still useful without a checkout, and `presets.sdk-root-unresolved`
102
+ is a FROZEN issue code (`contract/issue-codes.json`): the wizard matches it with
103
+ `===` to know its SoM list is empty for an environmental reason. Promote it to an
104
+ error and the wizard falls back to a static catalogue that carries no `cores`,
105
+ which scaffolds a heterogeneous SoM single-core with no IPC.
106
+
107
+ **Every failure is an envelope, never a traceback.** Both directory reads and
108
+ every file read swallow their IO errors exactly as the Rust's `read_dir(..)` /
109
+ `read_to_string(..).ok()` chains do -- a missing tree, an unreadable entry, a
110
+ non-UTF-8 byte or a wrong-shaped SoM is a skipped entry, never an abort -- and
111
+ the catch-all in `presets` is the backstop for whatever nobody enumerated.
112
+ """
113
+
114
+ from __future__ import annotations
115
+
116
+ import functools
117
+ import json
118
+ import os
119
+ import re
120
+ import sys
121
+ from dataclasses import dataclass, replace
122
+ from pathlib import Path
123
+ from typing import Callable
124
+
125
+ import typer
126
+
127
+ from tan.core.metadata_schema import (
128
+ missing_schema_note,
129
+ soc_spec_schema_path,
130
+ som_preset_schema_path,
131
+ validate_document,
132
+ )
133
+ from tan.core.os_class import allowed_os_for_core, infer_runtime_for_core_id
134
+ from tan.core.sdk_discovery import (
135
+ ActiveSdk,
136
+ global_default_foreign_project_issue,
137
+ project_pin_issue,
138
+ resolve_sdk_tiered,
139
+ with_sdk_search,
140
+ )
141
+ from tan.core.shapes import SDK_MARKER, rejected_sdk_root_message
142
+ from tan.core.som_schema_version import (
143
+ SOM_SCHEMA_VERSION,
144
+ is_supported_som_schema_version,
145
+ skipped_presets_message,
146
+ )
147
+ from tan.core.global_flags import accept_global_flags
148
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
149
+ from tan.exit_codes import ExitCode
150
+ from tan.output_format import FORMAT_HELP, OutputFormat
151
+ from tan.soc_ref import resolve_soc_path as _resolve_soc_path
152
+
153
+ #: `data.schemaVersion` for this command's payload.
154
+ DATA_SCHEMA_VERSION = "1"
155
+
156
+ #: `SOM_SCHEMA_VERSION` (imported above, `tan.core.som_schema_version`) is the
157
+ #: one `schema_version:` a SoM preset may declare. `parse_som_preset` refuses
158
+ #: anything else rather than guessing at a shape it does not know, so such a
159
+ #: preset is SKIPPED here, as the oracle skips an unknown version
160
+ #: (`parse_som_preset(..).ok()?`) -- but no longer silently: `read_soms`
161
+ #: collects every skipped version and `presets()` reports them as ONE
162
+ #: `SOM_SCHEMA_SKIPPED_CODE` warning (tan-cli#1278: against a pre-v2 SDK every
163
+ #: preset is skipped, and `skus: []` with no issue read as "this SDK has no
164
+ #: SoMs").
165
+
166
+ #: `warning`: SoMs the SDK ships are missing from the listing.
167
+ SOM_SCHEMA_SKIPPED_CODE = "presets.som-schema-version-skipped"
168
+
169
+ #: The FROZEN issue code for "no checkout resolved" (`contract/issue-codes.json`,
170
+ #: severity `warning`). Spelled once; the extension matches it with `===`.
171
+ SDK_UNRESOLVED_CODE = "presets.sdk-root-unresolved"
172
+ SDK_UNRESOLVED_MESSAGE = (
173
+ "alp-sdk root is unresolved. Returning built-in defaults and empty SDK preset lists."
174
+ )
175
+
176
+ #: What the reader GOT instead, for the `--sdk-root`-was-given-and-rejected
177
+ #: branch (`tan.core.shapes.rejected_sdk_root_message`, tan-cli#497). The no-flag
178
+ #: message above is byte-pinned by the `presets-no-sdk` golden envelope and the
179
+ #: `["presets","--format","json"]` oracle-parity case and is NOT touched; this
180
+ #: branch is unreachable from either, because neither passes the flag.
181
+ SDK_UNRESOLVED_CONSEQUENCE = "Returning built-in defaults and empty SDK preset lists."
182
+
183
+ #: Built-in, SDK-independent defaults, verbatim from
184
+ #: `tan_core::presets::empty_preset_catalogue`. Order is the wire order (these are
185
+ #: lists, not sets, in the envelope) and the golden pins it.
186
+ #:
187
+ #: `LIBRARIES` is the per-core `cores.<id>.libraries` token set; the ADR-0018
188
+ #: curated `boardLibraries` a top-level `libraries:` entry names are discovered
189
+ #: from the checkout instead -- two different vocabularies, which is why the
190
+ #: envelope carries both.
191
+ LIBRARIES = ["etl", "fmt", "nlohmann_json", "doctest", "lvgl", "mbedtls", "cmsis_dsp", "littlefs"]
192
+ INFERENCE_BACKENDS = ["auto", "cpu", "ethos_u", "drpai", "deepx_dxm1"]
193
+ LOG_LEVELS = ["error", "warn", "info", "debug", "trace"]
194
+ #: The OS VOCABULARY, not a picker: `cores[].os` is derived per core (see the
195
+ #: module docstring). Nothing may bind this list to a SoM or to a `--os` flag.
196
+ OS_CHOICES = ["zephyr", "yocto", "baremetal"]
197
+
198
+ #: SoM preset directory names/files this command considers, from `read_soms`.
199
+ SOM_PREFIX = "E1M-"
200
+ SOM_DIR_YAML = "som.yaml"
201
+
202
+ #: `board`/`machine` as a key of a FLOW mapping (`m33: { board: x }`), for the
203
+ #: no-PyYAML fallback. Anchored on a brace, comma or whitespace so `zephyr_board:`
204
+ #: is not mistaken for `board:`.
205
+ _FLOW_KEY_RE = re.compile(r"[{,\s](board|machine)\s*:")
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class SomCore:
210
+ """One core of a SoM topology: its id, the runtime that core's class runs,
211
+ its raw SoC core type, and the `os:` values that type may legally take
212
+ (tan-cli#870). `id`/`os` are the emitted-order fields the frozen
213
+ `SomCoreEntry` in `presets.rs` already carries; `type`/`allowed_os` are
214
+ additive and both default to their empty value (`""` / `()`) when the
215
+ checkout doesn't resolve them -- see the module docstring."""
216
+
217
+ id: str
218
+ os: str
219
+ type: str = ""
220
+ allowed_os: tuple[str, ...] = ()
221
+
222
+ def as_dict(self) -> dict[str, object]:
223
+ return {
224
+ "id": self.id,
225
+ "os": self.os,
226
+ "type": self.type,
227
+ "allowedOs": list(self.allowed_os),
228
+ }
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class Som:
233
+ """One discovered SoM preset. Field order matches `SomEntry`."""
234
+
235
+ sku: str
236
+ display_name: str
237
+ family: str
238
+ cores: tuple[SomCore, ...]
239
+
240
+ def as_dict(self) -> dict[str, object]:
241
+ return {
242
+ "sku": self.sku,
243
+ "displayName": self.display_name,
244
+ "family": self.family,
245
+ "cores": [c.as_dict() for c in self.cores],
246
+ }
247
+
248
+
249
+ class SomShapeError(Exception):
250
+ """The text is not a v2 SoM preset. Caller SKIPS the entry, as the oracle's
251
+ `parse_som_preset(..).ok()?` does -- one malformed SoM must not empty the
252
+ catalogue or fail the command."""
253
+
254
+
255
+ class SomSchemaVersionError(SomShapeError):
256
+ """The preset declares a `schema_version` this tan does not read. Still a
257
+ skip, but one `read_soms` can report: `.version` is what the file said."""
258
+
259
+ def __init__(self, version: object) -> None:
260
+ super().__init__(
261
+ f"unsupported schema_version {version!r} (this CLI consumes v{SOM_SCHEMA_VERSION})"
262
+ )
263
+ self.version = version
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # The derived runtime (tan_core::bootstrap::runtime_for_topology_core)
268
+ # ---------------------------------------------------------------------------
269
+
270
+
271
+ def runtime_for_core(core_id: str, *, board: bool, machine: bool) -> str:
272
+ """The runtime one topology entry naturally runs: `board:` -> zephyr,
273
+ `machine:` -> yocto, else the core-id heuristic.
274
+
275
+ `board` wins when both are present, matching the oracle's match arms. ONE
276
+ owner of this mapping in the Rust (`tan presets` reports it, `tan bootstrap`
277
+ gates on it) -- so when `bootstrap` lands in this port it must call THIS, not
278
+ a copy, or the two can disagree about which host can build the project.
279
+ """
280
+ if board:
281
+ return "zephyr"
282
+ if machine:
283
+ return "yocto"
284
+ return infer_runtime_for_core_id(core_id)
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # SoM preset reading (tan_core::sdk_catalogue::parse_som_preset, narrowed)
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def _clean(value: object) -> str | None:
293
+ """`str_clean`: a YAML scalar as a string, dropping the placeholder `TBD`.
294
+
295
+ Non-strings are `None` (serde's `as_str()`), and the value is NOT trimmed --
296
+ only the `TBD` comparison is -- because the oracle emits the scalar as
297
+ written.
298
+ """
299
+ if not isinstance(value, str):
300
+ return None
301
+ return None if value.strip() == "TBD" else value
302
+
303
+
304
+ def parse_som_preset(
305
+ text: str,
306
+ *,
307
+ core_type_lookup: "Callable[[str | None], dict[str, str]] | None" = None,
308
+ allowed_os_lookup: "Callable[[str], list[str]] | None" = None,
309
+ metadata_root: Path | None = None,
310
+ source: Path | str | None = None,
311
+ warnings: list[str] | None = None,
312
+ skipped: list[str] | None = None,
313
+ ) -> Som:
314
+ """The `sku`/`display_name`/`family`/`topology` slice of a SoM preset.
315
+
316
+ Narrower than the oracle's `parse_som_preset` on purpose: the envelope
317
+ carries exactly these four things (plus, per core, the tan-cli#870
318
+ `type`/`allowed_os` enrichment below), and reading `pad_routes`,
319
+ `i2c_devices` or `capabilities` here would be tan learning hardware facts it
320
+ has no field to report (I-26).
321
+
322
+ `schema_version` is guarded FIRST, before any other key is trusted -- the
323
+ same skew guard every other SDK-file consumer carries. Raises
324
+ `SomShapeError` for every unusable shape; the caller skips the entry.
325
+
326
+ *core_type_lookup*/*allowed_os_lookup* are optional so every existing
327
+ single-argument call (this file's own tests) keeps parsing `id`/`os` alone,
328
+ unchanged; `read_soms` is the one caller that supplies both, bound to the
329
+ checkout it read *text* from.
330
+
331
+ *metadata_root*/*source*/*warnings* (tan-cli#964): when *metadata_root* is
332
+ given, the RAW parsed YAML mapping (not this function's own reshaped
333
+ return value -- see `_load_som_yaml`'s `raw_out`) is checked against
334
+ `som-preset-v2.schema.json` and every violation is appended to *warnings*
335
+ (a caller-owned list, so several presets read in one `tan presets` run
336
+ share one collector rather than each returning its own list a caller has
337
+ to flatten). This is the WARN half of #964's decided rule -- `tan presets`
338
+ reports and continues, it never fails a listing over one schema-invalid
339
+ preset -- so a violation here is never raised, only reported.
340
+ `bootstrap_cmd`'s own call site passes these too, and REFUSES on a
341
+ non-empty result instead; the schema check itself does not know or care
342
+ which policy its caller applies. Only run past the `schema_version`
343
+ guard: a preset that fails THAT guard is already an unusable shape the
344
+ caller skips entirely (`SomShapeError`), and validating a document this
345
+ function is about to discard would just duplicate that verdict under a
346
+ different name. Silently skipped (not "validated clean") when the
347
+ no-PyYAML fallback parsed *text* -- `_load_som_yaml`'s `raw_out` stays
348
+ empty there, because `scan_som_preset` builds no full mapping to check.
349
+
350
+ *skipped* (tan-cli#964 review, major 6, "skip-but-disclose"): a second,
351
+ independent caller-owned collector -- when the schema file is simply
352
+ ABSENT, `validate_document` returns `[]` (nothing to add to *warnings*)
353
+ and this collects `missing_schema_note`'s disclosure instead, so a
354
+ caller can tell "checked, no violations" apart from "not checked at
355
+ all" rather than the two looking identical (`issues: []` either way).
356
+ """
357
+ raw_holder: list[dict[str, object]] = []
358
+ doc = _load_som_yaml(text, raw_out=raw_holder)
359
+ version = doc.get("schema_version")
360
+ # `bool` is excluded explicitly: Python's `True == 1` is true and `isinstance(
361
+ # True, int)` is too, so `schema_version: true` would pass the guard, where
362
+ # serde's `as_i64()` on a bool is `None` and the oracle rejects the file.
363
+ if not is_supported_som_schema_version(version):
364
+ raise SomSchemaVersionError(version)
365
+ if metadata_root is not None and raw_holder:
366
+ schema_path = som_preset_schema_path(metadata_root)
367
+ resolved_source = source if source is not None else "<som preset>"
368
+ if skipped is not None:
369
+ note = missing_schema_note(schema_path, source=resolved_source)
370
+ if note is not None:
371
+ skipped.append(note)
372
+ if warnings is not None:
373
+ warnings.extend(validate_document(raw_holder[0], schema_path, resolved_source))
374
+ sku = _clean(doc.get("sku")) or ""
375
+ silicon = _clean(doc.get("silicon"))
376
+ core_types = core_type_lookup(silicon) if core_type_lookup is not None else {}
377
+ # `.get`, not `[..]`: the guard above happens to reject every shape that
378
+ # reaches here without a `cores` key, but a KeyError is the one failure
379
+ # this command may not have, so it does not lean on that.
380
+ raw_cores: tuple[SomCore, ...] = tuple(doc.get("cores") or ())
381
+ cores = tuple(
382
+ replace(
383
+ c,
384
+ type=core_types.get(c.id, ""),
385
+ allowed_os=tuple(allowed_os_lookup(core_types.get(c.id, "")))
386
+ if allowed_os_lookup is not None
387
+ else (),
388
+ )
389
+ for c in raw_cores
390
+ )
391
+ return Som(
392
+ sku=sku,
393
+ # `display_name` falls back to `sku`, then to `""`.
394
+ display_name=_clean(doc.get("display_name")) or sku,
395
+ family=_clean(doc.get("family")) or "",
396
+ cores=cores,
397
+ )
398
+
399
+
400
+ def _load_som_yaml(text: str, *, raw_out: list[dict[str, object]] | None = None) -> dict[str, object]:
401
+ """`{schema_version, sku, display_name, family, silicon, cores}` from a SoM
402
+ preset.
403
+
404
+ PyYAML when it is importable, else `scan_som_preset`. tan's runtime
405
+ dependencies are `typer` + `rich` only -- and the frozen binary is built from
406
+ a venv holding exactly those (`scripts/build_binary.sh`), so on the SHIPPED
407
+ artifact the fallback is not a rare path, it is THE path. It therefore may
408
+ not be a degraded answer: `cores` missing or empty is the one thing this
409
+ command must never quietly report, because the wizard scaffolds IPC off it
410
+ and a heterogeneous SoM would come out single-core.
411
+
412
+ `cores` is already a `list[SomCore]` here rather than raw topology, because
413
+ the two readers see topology differently (a parsed mapping vs. indented
414
+ lines) and the runtime derivation must happen once, in `runtime_for_core`.
415
+
416
+ *raw_out* (tan-cli#964), when given, gets the RAW `yaml.safe_load` mapping
417
+ appended (before any reshaping) -- the shape `som-preset-v2.schema.json`
418
+ actually describes (a `topology:` object, not this function's own `cores:
419
+ list[SomCore]` return value, which the schema has never seen and would
420
+ reject on sight). Left empty on the no-PyYAML fallback: `scan_som_preset`
421
+ never builds a full mapping, only the handful of scalars/cores this
422
+ function's own return shape needs, so there is no raw document to
423
+ validate there -- `parse_som_preset` treats an empty `raw_out` as "cannot
424
+ validate", not as "validated clean".
425
+ """
426
+ try:
427
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
428
+ except ImportError:
429
+ return scan_som_preset(text)
430
+ try:
431
+ doc = yaml.safe_load(text)
432
+ except Exception as err: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises
433
+ raise SomShapeError(f"could not be parsed as YAML: {err}") from err
434
+ if not isinstance(doc, dict):
435
+ # `root.as_mapping().unwrap_or_default()` -> an empty map -> the
436
+ # schema_version guard rejects it. Same outcome, stated here.
437
+ return {}
438
+ if raw_out is not None:
439
+ raw_out.append(doc)
440
+ topology = doc.get("topology")
441
+ cores: list[SomCore] = []
442
+ if isinstance(topology, dict):
443
+ for core_id, node in topology.items():
444
+ if not isinstance(core_id, str):
445
+ continue # `id.as_str()?` -- a non-string key is not a core
446
+ entry = node if isinstance(node, dict) else {}
447
+ cores.append(
448
+ SomCore(
449
+ core_id,
450
+ runtime_for_core(
451
+ core_id,
452
+ board=_clean(entry.get("board")) is not None,
453
+ machine=_clean(entry.get("machine")) is not None,
454
+ ),
455
+ )
456
+ )
457
+ return {
458
+ "schema_version": doc.get("schema_version"),
459
+ "sku": doc.get("sku"),
460
+ "display_name": doc.get("display_name"),
461
+ "family": doc.get("family"),
462
+ "silicon": doc.get("silicon"),
463
+ "cores": cores,
464
+ }
465
+
466
+
467
+ def scan_som_preset(text: str) -> dict[str, object]:
468
+ """The no-PyYAML reader: the five top-level scalars (`schema_version`,
469
+ `sku`, `display_name`, `family`, `silicon`) plus the `topology:` block's
470
+ core ids and whether each declares `board:`/`machine:`.
471
+
472
+ Deliberately NOT a YAML parser -- it answers only what the envelope carries,
473
+ the same bargain `validate_cmd._load_yaml` and `generate_cmd._scan_som_sku`
474
+ strike. What it must get right on the real files is the topology, which in
475
+ every shipped SoM preset is a plain two-space block mapping with interleaved
476
+ comments; the flow form (`m33: { board: x }`) is handled too because the
477
+ oracle's own unit tests use it.
478
+
479
+ Ponytail note: line-oriented, so a block scalar or an anchor in a SoM preset
480
+ would read wrong. No shipped preset has either; PyYAML covers them wherever
481
+ it is installed, and the upgrade path if one appears is to vendor a real
482
+ parser, not to widen this.
483
+ """
484
+ out: dict[str, object] = {}
485
+ cores: list[SomCore] = []
486
+ # `None` until `topology:` opens; then the indent its core ids sit at.
487
+ topology_indent: int | None = None
488
+ in_topology = False
489
+ # The core id being read, and which of `board:`/`machine:` it has declared.
490
+ open_core: list = [None, False, False]
491
+
492
+ def close_core() -> None:
493
+ core_id, board, machine = open_core
494
+ if core_id is not None:
495
+ cores.append(
496
+ SomCore(core_id, runtime_for_core(core_id, board=board, machine=machine))
497
+ )
498
+ open_core[:] = [None, False, False]
499
+
500
+ for raw in text.splitlines():
501
+ stripped = raw.strip()
502
+ if not stripped or stripped.startswith("#"):
503
+ continue
504
+ indent = len(raw) - len(raw.lstrip())
505
+ key, sep, value = stripped.partition(":")
506
+ key = key.strip()
507
+
508
+ if indent == 0:
509
+ close_core()
510
+ in_topology = bool(sep) and key == "topology"
511
+ topology_indent = None
512
+ if not sep:
513
+ continue
514
+ if key == "schema_version":
515
+ out["schema_version"] = _scalar_int(value)
516
+ elif key in ("sku", "display_name", "family", "silicon"):
517
+ out[key] = _scalar(value)
518
+ continue
519
+
520
+ if not in_topology or not sep:
521
+ continue
522
+ if topology_indent is None:
523
+ topology_indent = indent
524
+ if indent <= topology_indent:
525
+ # A core id. Its value may be an inline flow mapping carrying the
526
+ # very keys the block form puts on the following lines.
527
+ close_core()
528
+ open_core[0] = key
529
+ for flow_key in _FLOW_KEY_RE.findall(f" {value}"):
530
+ open_core[1 if flow_key == "board" else 2] = True
531
+ elif open_core[0] is not None:
532
+ # A key of the current core. `_scalar` so an empty or `TBD` value
533
+ # does not count as declared -- `str_clean` drops both.
534
+ if key == "board" and _scalar(value) is not None:
535
+ open_core[1] = True
536
+ elif key == "machine" and _scalar(value) is not None:
537
+ open_core[2] = True
538
+ close_core()
539
+ out["cores"] = cores
540
+ return out
541
+
542
+
543
+ def _scalar(value: str) -> str | None:
544
+ """A block-mapping scalar: trailing ` #comment` dropped, surrounding quotes
545
+ stripped, `TBD` and empty treated as absent (`str_clean`)."""
546
+ text = value
547
+ hashed = text.find(" #")
548
+ if hashed != -1:
549
+ text = text[:hashed]
550
+ text = text.strip()
551
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in "\"'":
552
+ text = text[1:-1]
553
+ return _clean(text) or None
554
+
555
+
556
+ def _scalar_int(value: str) -> int | None:
557
+ """A BARE integer scalar, or `None`. Quotes are not stripped first, so
558
+ `schema_version: "1"` is `None` here exactly as serde's `as_i64()` on a
559
+ string is -- a quoted version is not a v1 preset."""
560
+ text = value.split(" #", 1)[0].strip()
561
+ try:
562
+ return int(text)
563
+ except ValueError:
564
+ return None
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # Discovery
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def _entries(directory: Path) -> list[os.DirEntry]:
573
+ """`directory`'s entries, or `[]` when it will not read -- `read_dir(..)`'s
574
+ `else { return Vec::new() }`. A missing `metadata/` tree is an empty
575
+ catalogue, not an error."""
576
+ try:
577
+ with os.scandir(directory) as it:
578
+ return list(it)
579
+ except OSError:
580
+ return []
581
+
582
+
583
+ # `_resolve_soc_path` (tan-cli#917): was a verbatim second copy of the path
584
+ # arithmetic `tan.planner.som_metadata.resolve_soc_path` also implements --
585
+ # now both import the single definition in the leaf module `tan.soc_ref`,
586
+ # which imports neither `tan.planner` nor anything else that reads real files
587
+ # at import time, so importing it here does not reintroduce the `bind_sdk_root`
588
+ # rebinding hazard `_soc_lookups`'s docstring (below) documents avoiding.
589
+
590
+
591
+ def _soc_lookups(
592
+ sdk_root: str,
593
+ *,
594
+ warnings: list[str] | None = None,
595
+ skipped: list[str] | None = None,
596
+ ) -> tuple[
597
+ "Callable[[str | None], dict[str, str]] | None",
598
+ "Callable[[str], list[str]] | None",
599
+ ]:
600
+ """The tan-cli#870 `(core_type_lookup, allowed_os_lookup)` pair, bound to
601
+ *sdk_root*, or `(None, None)` when *sdk_root* names no metadata tree at
602
+ all (matching `read_soms`'s own "missing tree is an empty catalogue, not
603
+ an error").
604
+
605
+ `core_type_lookup` resolves a SoM's `silicon:` key to
606
+ `metadata/socs/<vendor>/<family>/<part>.json` and reads its `cores[].type`
607
+ strings. `allowed_os_lookup` reuses `tan.core.os_class` -- the SAME
608
+ cortex-a/cortex-m convention `tan.planner.validate._enforce_os_matches_core_class`
609
+ gates a build on -- run against *sdk_root*'s own `board.schema.json` `os:`
610
+ enum, so this command's answer and the build-time rule cannot drift apart
611
+ (the whole point of #870).
612
+
613
+ **Deliberately does NOT import `tan.planner`.** `tan.planner` requires
614
+ `bind_sdk_root` before its first import and then refuses to rebind to a
615
+ different root for the rest of the process -- the right contract for
616
+ `tan build`'s one big planning pass per process, the wrong one for `tan
617
+ presets`, which `read_soms` calls from dozens of independent tests each
618
+ against their own disposable synthetic SDK root. Routing through it here
619
+ made the FIRST such test to run in an xdist worker permanently bind that
620
+ whole worker to its throwaway root, so every later test in it that needed
621
+ a DIFFERENT (often the real) root failed outright -- measured while
622
+ building #870: 292 unrelated `tests/parity/test_planner_emit_parity.py`
623
+ cases turned from passing to `PlannerRootError` this way. `tan.core.os_class`
624
+ exists precisely so this file (and any future binding-free consumer) can
625
+ reuse the convention without paying that cost; see its own docstring.
626
+ Every IO error below (a missing/unreadable/non-JSON SoC or schema file) is
627
+ a skipped/empty answer, never a raise -- `type`/`allowedOs` degrade to
628
+ their empty default the same way every other field in this file does.
629
+
630
+ *warnings* (tan-cli#964), when given, collects every `soc-spec-v1`
631
+ violation `core_type_lookup` finds -- the WARN half of #964's decided
632
+ rule; `tan presets` still degrades `type` to `""` exactly as before (the
633
+ comment inside `core_type_lookup` below is UNCHANGED, the `isinstance`
634
+ guard stays), it just now also SAYS which file and field it degraded.
635
+ Deduplicated by `soc_path` within one `_soc_lookups` binding, so a family
636
+ of SoMs that share one `silicon:` ref (common -- V2N101/V2N102/V2M101/
637
+ V2M102 are one PCB) does not repeat the same violation once per SoM.
638
+
639
+ *skipped* (tan-cli#964 review, major 6): the same "skip-but-disclose"
640
+ collector `parse_som_preset` takes, deduplicated the same way *warnings*
641
+ is above.
642
+ """
643
+ root = Path(sdk_root) / "metadata"
644
+ if not root.is_dir():
645
+ return None, None
646
+
647
+ _warned_soc_paths: set[str] = set()
648
+
649
+ def core_type_lookup(silicon: str | None) -> dict[str, str]:
650
+ soc_path = _resolve_soc_path(silicon, root)
651
+ if soc_path is None:
652
+ return {}
653
+ try:
654
+ doc = json.loads(soc_path.read_text(encoding="utf-8"))
655
+ except (OSError, UnicodeDecodeError, ValueError):
656
+ return {}
657
+ if str(soc_path) not in _warned_soc_paths:
658
+ _warned_soc_paths.add(str(soc_path))
659
+ schema_path = soc_spec_schema_path(root)
660
+ if skipped is not None:
661
+ note = missing_schema_note(schema_path, source=soc_path)
662
+ if note is not None:
663
+ skipped.append(note)
664
+ if warnings is not None:
665
+ warnings.extend(validate_document(doc, schema_path, soc_path))
666
+ cores = doc.get("cores") if isinstance(doc, dict) else None
667
+ if not isinstance(cores, list):
668
+ return {}
669
+ # `c.get("type", "")` is UNVALIDATED against `soc-spec-v1.schema.json`'s
670
+ # own `"type": {"type": "string"}` -- a clean, schema-valid SDK never
671
+ # produces anything else, but this file's whole job (tan-cli#870) is
672
+ # reading a `--sdk-root` tree `validate_metadata.py` never ran against,
673
+ # exactly the hand-authored/mid-port state `porting-a-new-som` leaves a
674
+ # SoC JSON in. A non-string `type` here is NOT a `str | None` under
675
+ # `isinstance`, and this dict feeds BOTH `SomCore.type` (written
676
+ # verbatim to the wire by `as_dict`) and `allowed_os_for_core`'s
677
+ # `(core_type or "").lower()` -- a non-string reaching either one is
678
+ # a wire leak or an `AttributeError` respectively (tan-cli#957). Every
679
+ # non-string normalises to `""`, the SAME "unresolved" sentinel a
680
+ # missing/unreadable SoC file already degrades `type` to two lines
681
+ # above -- a malformed `type` and a missing one are indistinguishable
682
+ # to every downstream consumer, on purpose.
683
+ return {
684
+ c["id"]: c["type"] if isinstance(c.get("type"), str) else ""
685
+ for c in cores
686
+ if isinstance(c, dict) and isinstance(c.get("id"), str)
687
+ }
688
+
689
+ @functools.lru_cache(maxsize=None)
690
+ def _os_choices() -> tuple[str, ...] | None:
691
+ # No `BOARD_SCHEMA`-style fallback to a globally bound "in-tree"
692
+ # schema: unlike the planner, `presets` never has a second tree to
693
+ # fall back to -- it is reading exactly one checkout throughout, so a
694
+ # checkout with no schema of its own answers `None` (degrade), not a
695
+ # DIFFERENT checkout's enum.
696
+ schema_path = root / "schemas" / "board.schema.json"
697
+ try:
698
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
699
+ return tuple(schema["$defs"]["core_entry"]["properties"]["os"]["enum"])
700
+ except (OSError, UnicodeDecodeError, ValueError, KeyError, TypeError):
701
+ return None
702
+
703
+ def allowed_os_lookup(core_type: str) -> list[str]:
704
+ choices = _os_choices()
705
+ if choices is None:
706
+ return []
707
+ # `allowed_os_for_core` (tan.core.os_class) is the single place the
708
+ # "" == unresolved sentinel -> [] degrade lives, shared with
709
+ # `tan.planner.topology._allowed_os_for_core` -- see its own
710
+ # docstring for why (tan-cli#914: the two must never drift apart on
711
+ # this, the same reason this file reuses `tan.core.os_class` at all).
712
+ return allowed_os_for_core(core_type, choices)
713
+
714
+ return core_type_lookup, allowed_os_lookup
715
+
716
+
717
+ def read_soms(
718
+ sdk_root: str,
719
+ *,
720
+ warnings: list[str] | None = None,
721
+ skipped: list[str] | None = None,
722
+ version_skipped: list[object] | None = None,
723
+ ) -> list[Som]:
724
+ """SoM presets under `<sdk>/metadata/e1m_modules`, sorted by SKU.
725
+
726
+ Both layouts the SDK has used are supported, as in the oracle: a flat
727
+ `E1M-X.yaml`, or an `E1M-X/som.yaml` directory. An entry that is not
728
+ `E1M-*`, carries no yaml, will not read, is not UTF-8, or is not a v2 preset
729
+ is skipped -- never an error.
730
+
731
+ *version_skipped* (tan-cli#1278), when given, collects the declared
732
+ `schema_version` of every preset skipped for declaring the wrong one, so
733
+ the caller can say so instead of returning a silently shorter list.
734
+
735
+ *warnings* (tan-cli#964), when given, collects every `soc-spec-v1` /
736
+ `som-preset-v2` violation found while reading -- see `_soc_lookups` and
737
+ `parse_som_preset`'s own docstrings for what feeds it. *skipped*
738
+ (tan-cli#964 review, major 6) does the same for the "skip-but-disclose"
739
+ half: a note per schema file this walk found simply absent.
740
+ """
741
+ metadata_root = Path(sdk_root) / "metadata"
742
+ root = metadata_root / "e1m_modules"
743
+ core_type_lookup, allowed_os_lookup = _soc_lookups(
744
+ sdk_root, warnings=warnings, skipped=skipped
745
+ )
746
+ found: list[Som] = []
747
+ for entry in _entries(root):
748
+ if not entry.name.startswith(SOM_PREFIX):
749
+ continue
750
+ path = Path(entry.path)
751
+ if _is_dir(entry):
752
+ yaml_path = path / SOM_DIR_YAML
753
+ elif entry.name.endswith(".yaml"):
754
+ yaml_path = path
755
+ else:
756
+ continue
757
+ try:
758
+ text = yaml_path.read_text(encoding="utf-8")
759
+ except (OSError, UnicodeDecodeError, ValueError):
760
+ # `read_to_string(..).ok()?`. `encoding="utf-8"` explicitly (I-27):
761
+ # the default decodes with the host locale, and the shipped presets
762
+ # carry non-ASCII prose, so a bare read raises on a cp1252 Windows
763
+ # host and passes on ubuntu CI.
764
+ continue
765
+ try:
766
+ found.append(
767
+ parse_som_preset(
768
+ text,
769
+ core_type_lookup=core_type_lookup,
770
+ allowed_os_lookup=allowed_os_lookup,
771
+ metadata_root=metadata_root,
772
+ source=yaml_path,
773
+ warnings=warnings,
774
+ skipped=skipped,
775
+ )
776
+ )
777
+ except SomSchemaVersionError as err:
778
+ if version_skipped is not None:
779
+ version_skipped.append(err.version)
780
+ continue
781
+ except SomShapeError:
782
+ continue
783
+ found.sort(key=lambda s: s.sku)
784
+ return found
785
+
786
+
787
+ def _is_dir(entry: os.DirEntry) -> bool:
788
+ """`entry.path.is_dir()` -- symlinks FOLLOWED, matching the oracle's
789
+ `Path::is_dir()` (not the non-following `DirEntry::file_type()` that
790
+ `examples` mirrors). `OSError` -> not a directory."""
791
+ try:
792
+ return entry.is_dir()
793
+ except OSError:
794
+ return False
795
+
796
+
797
+ def read_board_libraries(sdk_root: str) -> list[str]:
798
+ """The ADR-0018 curated library names: the stem of each
799
+ `<sdk>/metadata/libraries/<name>.yaml`, sorted and de-duplicated.
800
+
801
+ `README*` is skipped case-insensitively; a non-yaml entry is skipped. These
802
+ are the values a board.yaml TOP-LEVEL `libraries:` entry names -- a different
803
+ vocabulary from the built-in per-core `LIBRARIES` tokens.
804
+ """
805
+ root = Path(sdk_root) / "metadata" / "libraries"
806
+ names: set[str] = set()
807
+ for entry in _entries(root):
808
+ name = entry.name
809
+ if not name.endswith(".yaml") or name.lower().startswith("readme"):
810
+ continue
811
+ # `trim_end_matches(".yaml")` strips REPEATED suffixes, so `a.yaml.yaml`
812
+ # is `a`; `removesuffix` would leave `a.yaml`.
813
+ while name.endswith(".yaml"):
814
+ name = name[: -len(".yaml")]
815
+ names.add(name)
816
+ return sorted(names)
817
+
818
+
819
+ # ---------------------------------------------------------------------------
820
+ # Project/SDK resolution (util.rs::resolve_cli_project_context)
821
+ # ---------------------------------------------------------------------------
822
+
823
+
824
+ def _posix(value: str) -> str:
825
+ """`to_posix`: backslashes to forward slashes, nothing else.
826
+
827
+ A string, never a `Path` round-trip: `str(Path("./sdk"))` is `sdk`, dropping
828
+ the `./` the user typed, while Rust's `Path::new("./sdk").to_string_lossy()`
829
+ keeps it -- and `./sdk` is what the golden pins for both `sdk.root` and
830
+ `data.sdkRoot`.
831
+ """
832
+ return value.replace("\\", "/")
833
+
834
+
835
+ def resolve_project_paths(project: str | None, board_yaml: str | None) -> tuple[str, str]:
836
+ """`(project.root, project.boardYaml)` as the envelope reports them: the
837
+ normalised absolute workspace root, and `board.yaml` under it.
838
+
839
+ Normalised (`.` dropped, `..` popped) like `normalize_path`, because the root
840
+ is joined from the CWD and `--project`. The board path is then joined WITHOUT
841
+ normalising, matching `resolve_board_yaml_path` -- `--board-yaml ../x.yaml`
842
+ reports `<root>/../x.yaml` on both implementations.
843
+ """
844
+ try:
845
+ cwd = os.getcwd()
846
+ except OSError:
847
+ cwd = "." # `current_dir().unwrap_or_else(|_| PathBuf::from("."))`
848
+ root = _posix(os.path.normpath(os.path.join(cwd, project or ".")))
849
+ leaf = board_yaml or "board.yaml"
850
+ if os.path.isabs(leaf):
851
+ return root, _posix(leaf)
852
+ # No separator when the root already ends in one: at a drive root (`C:/`)
853
+ # a blind join would emit `C://board.yaml`.
854
+ sep = "" if root.endswith(("/", "\\")) else "/"
855
+ return root, f"{root}{sep}{_posix(leaf)}"
856
+
857
+
858
+ def resolve_sdk(sdk_root: str | None, workspace_root: str) -> ActiveSdk:
859
+ """The resolved checkout as an `ActiveSdk` (posix `.path`), `.path is None`
860
+ when nothing usable resolved.
861
+
862
+ `resolve_sdk_tiered` walks the four tiers (`--sdk-root` > project pin >
863
+ global default > discovery) and is TERMINAL on `--sdk-root` (I-31): a typo'd
864
+ flag never falls through to a lower tier and silently reports a different
865
+ SDK. The loader-marker check here is what core's `resolve_project_context`
866
+ re-applies on top of that -- so an invalid `--sdk-root` resolves to nothing
867
+ (empty lists + the frozen warning), which is the oracle's behaviour and NOT
868
+ the same as "resolved something else".
869
+
870
+ `.broken_project_pin` (tan-cli#263 review) and `.foreign_global_default_for`
871
+ (tan-cli#464) both carry through onto the returned `ActiveSdk` -- INCLUDING
872
+ when this call does NOT resolve to a usable checkout (tan-cli#468). This
873
+ used to return a bare `None` on that branch (`active.path` is `None`, or
874
+ the loader marker is missing), dropping both facts on the floor: neither
875
+ caller could surface a fact it was never given, so a workspace with, say,
876
+ a broken `.alp/sdk-path` pin AND no OTHER tier resolving anything reported
877
+ `presets.sdk-root-unresolved` / `clean.sdk-root-not-found` alone, with no
878
+ `sdk.project-pin-unresolved` alongside it -- the same silent-drop shape
879
+ tan-cli#263/#464 exist to close, just on the "resolved to nothing at all"
880
+ branch rather than the "resolved to something else" one those issues
881
+ named. Both callers now check `.path is not None` rather than this
882
+ function's own truthiness -- an `ActiveSdk` with `path=None` is still
883
+ truthy (it is never `None` any more), and unpacking one is exactly how a
884
+ caller reads `broken_project_pin`/`foreign_global_default_for` even when
885
+ there is no checkout to report.
886
+ Returned as `ActiveSdk` itself, not a growing tuple: a fact one caller
887
+ needs must not force every other caller's unpacking to grow a matching
888
+ positional slot (the same reasoning `tan.core.sdk_discovery.SdkRootResolution`
889
+ documents).
890
+ """
891
+ active = resolve_sdk_tiered(sdk_root, Path(workspace_root))
892
+ if active.path is None or not Path(active.path).joinpath(*SDK_MARKER).exists():
893
+ return ActiveSdk(
894
+ None, active.tier, active.broken_project_pin, active.foreign_global_default_for
895
+ )
896
+ return ActiveSdk(
897
+ _posix(active.path), active.tier, active.broken_project_pin,
898
+ active.foreign_global_default_for,
899
+ )
900
+
901
+
902
+ def _version_skipped_issue(sdk_path: str, versions: list[object]) -> Issue:
903
+ """ONE warning for every preset `read_soms` skipped on `schema_version`
904
+ (tan-cli#1278), not one per preset: against a pre-v2 SDK that is every
905
+ preset it ships, and twenty identical lines bury the remedy."""
906
+ count = len(versions)
907
+ noun = "SoM preset" if count == 1 else "SoM presets"
908
+ where = _posix(str(Path(sdk_path) / "metadata" / "e1m_modules"))
909
+ return Issue(
910
+ SOM_SCHEMA_SKIPPED_CODE,
911
+ "warning",
912
+ skipped_presets_message(f"skipped {count} {noun} under {where}", versions),
913
+ )
914
+
915
+
916
+ # ---------------------------------------------------------------------------
917
+ # Text rendering
918
+ # ---------------------------------------------------------------------------
919
+
920
+
921
+ def render_presets_text(
922
+ soms: list[Som], board_libraries: list[str], verbose: bool
923
+ ) -> list[str]:
924
+ """The count line, then one line per SoM.
925
+
926
+ Takes `Som` rather than the bare SKU strings it used to: the count line
927
+ answered nothing on its own, and every field the fuller answer needs was
928
+ already parsed and then thrown away by the caller.
929
+ """
930
+ lines = [
931
+ f"presets: skus={len(soms)} libraries={len(LIBRARIES)} "
932
+ f"boardLibraries={len(board_libraries)}"
933
+ ]
934
+ if not soms:
935
+ return lines
936
+ sku_width = max(len(s.sku) for s in soms)
937
+ for som in soms:
938
+ lines.append(f" {som.sku:<{sku_width}} {som.display_name}")
939
+ if verbose:
940
+ cores = ", ".join(f"{c.id} ({c.os})" for c in som.cores)
941
+ lines.append(f" {'':<{sku_width}} family: {som.family}; cores: {cores}")
942
+ return lines
943
+
944
+
945
+ # ---------------------------------------------------------------------------
946
+ # Entry point
947
+ # ---------------------------------------------------------------------------
948
+
949
+
950
+ def presets(
951
+ project: str = typer.Option(
952
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
953
+ ),
954
+ board_yaml: str = typer.Option(
955
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
956
+ ),
957
+ sdk_root: str = typer.Option(None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."),
958
+ verbose: bool = typer.Option(
959
+ False, "--verbose", help="Also show each SKU's family and core/OS pairs."
960
+ ),
961
+ output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
962
+ ) -> None:
963
+ """List the SDK's SoM presets plus tan's built-in defaults."""
964
+ json_mode = output_format == "json"
965
+
966
+ root, board_path = ".", "./board.yaml"
967
+ sdk: ActiveSdk | None = None
968
+ soms: list[Som] = []
969
+ board_libraries: list[str] = []
970
+ issues: list[Issue] = []
971
+ exit_code = ExitCode.SUCCESS
972
+ try:
973
+ root, board_path = resolve_project_paths(project, board_yaml)
974
+ sdk = resolve_sdk(sdk_root, root)
975
+ # tan-cli#468: `resolve_sdk` now always returns an `ActiveSdk` -- never
976
+ # a bare `None` -- so `broken_project_pin`/`foreign_global_default_for`
977
+ # are read off it unconditionally, whether or not `.path` resolved.
978
+ # The `--sdk-root` case is unaffected: that tier is terminal, so both
979
+ # fields stay `None` there regardless of which branch runs below.
980
+ issues = [
981
+ issue
982
+ for issue in (
983
+ project_pin_issue(sdk.broken_project_pin, sdk.tier),
984
+ global_default_foreign_project_issue(sdk.foreign_global_default_for),
985
+ )
986
+ if issue is not None
987
+ ]
988
+ if sdk.path is not None:
989
+ schema_warnings: list[str] = []
990
+ schema_skipped: list[str] = []
991
+ version_skipped: list[object] = []
992
+ soms = read_soms(
993
+ sdk.path, warnings=schema_warnings, skipped=schema_skipped,
994
+ version_skipped=version_skipped,
995
+ )
996
+ board_libraries = read_board_libraries(sdk.path)
997
+ # tan-cli#964: warn-and-continue, never refuse -- `tan presets`
998
+ # already tolerates a missing/malformed SoM detail (see the
999
+ # module docstring's "no SoM detail here is load-bearing enough
1000
+ # to fail `tan presets` over"); a schema-invalid `cores[].type`
1001
+ # is now VISIBLE (named file, pointer, and what was found) rather
1002
+ # than silently degrading to `type: ""` with no trace of why.
1003
+ issues.extend(
1004
+ Issue("presets.metadata-schema-invalid", "warning", w)
1005
+ for w in schema_warnings
1006
+ )
1007
+ # tan-cli#964 review (major 6, "skip-but-disclose"): an absent
1008
+ # schema file used to degrade SILENTLY (`schema_warnings` stays
1009
+ # empty because there was nothing to check against), which is
1010
+ # `issues: []` -- byte-identical to a validated-clean run for a
1011
+ # `--format json` consumer. `info`, not `warning`: nothing here
1012
+ # says a document is wrong. Deduplicated (`dict.fromkeys`): every
1013
+ # SoM this run reads through the same missing schema reports it
1014
+ # once, not once per SoM.
1015
+ issues.extend(
1016
+ Issue("presets.metadata-schema-unchecked", "info", w)
1017
+ for w in dict.fromkeys(schema_skipped)
1018
+ )
1019
+ if version_skipped:
1020
+ issues.append(_version_skipped_issue(sdk.path, version_skipped))
1021
+ else:
1022
+ # tan-cli#497 defect 7 (the sibling of `examples_cmd`'s): a
1023
+ # rejected `--sdk-root` is named, so the reader can see WHICH path
1024
+ # failed the loader-marker check instead of only that "the alp-sdk
1025
+ # root is unresolved".
1026
+ issues.append(
1027
+ Issue(
1028
+ SDK_UNRESOLVED_CODE,
1029
+ "warning",
1030
+ rejected_sdk_root_message(sdk_root, SDK_UNRESOLVED_CONSEQUENCE)
1031
+ if sdk_root
1032
+ else with_sdk_search(SDK_UNRESOLVED_MESSAGE, root),
1033
+ )
1034
+ )
1035
+ except Exception as err: # noqa: BLE001 -- the backstop; see the module docstring
1036
+ # No registry entry applies (`contract/issue-codes.json` covers
1037
+ # `bootstrap.*`/`debug-config.*` only), so this follows the port's
1038
+ # `<command>.internal-failure` spelling. Unreachable through any
1039
+ # enumerated path -- every IO and parse error above is already swallowed
1040
+ # the way the oracle swallows it -- and here so the one nobody thought of
1041
+ # arrives as an envelope instead of a traceback on stderr with an empty
1042
+ # stdout the extension renders as nothing.
1043
+ #
1044
+ # This claim was FALSE for a while without anyone noticing (tan-cli#957):
1045
+ # `core_type_lookup`'s `cores[].type` read had no `isinstance` guard,
1046
+ # so a schema-invalid SoC JSON with a truthy non-string `type` (an
1047
+ # int, a list, a dict, `true`) reached `os_class.py`'s
1048
+ # `(core_type or "").lower()` and raised `AttributeError` right here,
1049
+ # through this very backstop -- an enumerated path this comment did
1050
+ # not count. `core_type_lookup` and `tan.core.os_class` both guard
1051
+ # that input now (see their own docstrings), closing THIS module's
1052
+ # path. That is what is actually known -- not that every path into
1053
+ # this `except` is closed for good: tan-cli#962 found the identical
1054
+ # unguarded read still live in `tan.planner.kconfig._emit_inference`
1055
+ # after this same sweep, on a different call site this backstop
1056
+ # cannot see. This `except` exists because that guarantee -- "every
1057
+ # path a caller might one day add is guarded" -- is not something
1058
+ # this file can check, not because it has been proven true.
1059
+ sdk, soms, board_libraries = None, [], []
1060
+ issues = [
1061
+ Issue(
1062
+ "presets.internal-failure",
1063
+ "error",
1064
+ f"presets failed unexpectedly: {err.__class__.__name__}: {err}",
1065
+ )
1066
+ ]
1067
+ exit_code = ExitCode.INTERNAL_FAILURE
1068
+
1069
+ skus = [s.sku for s in soms]
1070
+ if json_mode:
1071
+ emit(
1072
+ Envelope(
1073
+ "presets",
1074
+ Project.resolved(root, board_path),
1075
+ {
1076
+ "schemaVersion": DATA_SCHEMA_VERSION,
1077
+ # `null`, not omitted, when unresolved: the wizard reads this
1078
+ # key directly and the golden pins the null.
1079
+ "sdkRoot": sdk.path if sdk is not None else None,
1080
+ "skus": skus,
1081
+ "soms": [s.as_dict() for s in soms],
1082
+ "libraries": LIBRARIES,
1083
+ "boardLibraries": board_libraries,
1084
+ "inferenceBackends": INFERENCE_BACKENDS,
1085
+ "logLevels": LOG_LEVELS,
1086
+ "osChoices": OS_CHOICES,
1087
+ },
1088
+ issues,
1089
+ exit_code,
1090
+ # tan-cli#468: `sdk` is now a real `ActiveSdk` (never bare
1091
+ # `None`) whenever `resolve_sdk` ran at all -- `.path` is what
1092
+ # says whether it found a usable checkout, `sdk is not None`
1093
+ # alone no longer does.
1094
+ sdk=SdkInfo.from_resolution(sdk.path, sdk)
1095
+ if sdk is not None and sdk.path is not None
1096
+ else None,
1097
+ )
1098
+ )
1099
+ else:
1100
+ for line in render_presets_text(soms, board_libraries, verbose):
1101
+ print(line, file=sys.stderr)
1102
+ for issue in issues:
1103
+ # tan-cli#964 review (minor 11): the two schema codes' own
1104
+ # `message` (`<file>: <pointer>: <what jsonschema found>` /
1105
+ # `<file>: not validated -- no schema at <path>...`) does not
1106
+ # itself say "schema" for the INVALID half, so a text-mode
1107
+ # reader could not tell a schema violation apart from any other
1108
+ # warning at a glance -- unlike the REFUSE path's own
1109
+ # "does not validate against soc-spec-v1" wording. Tagged here,
1110
+ # at the print site, rather than folded into `message` itself:
1111
+ # `message` is the same string on the JSON envelope, which this
1112
+ # PR's own tests (and the review's own worked example) pin
1113
+ # verbatim.
1114
+ tag = "schema: " if issue.code.endswith(".metadata-schema-invalid") else ""
1115
+ print(f"presets: {tag}{issue.message}", file=sys.stderr)
1116
+ raise typer.Exit(int(exit_code))
1117
+
1118
+
1119
+ # tan-cli#261: adds the six oracle `GlobalArgs` flags this command was still
1120
+ # missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/
1121
+ # `--target`) on top of `--board-yaml`/`--verbose`, already declared and read
1122
+ # above; see `tan.core.global_flags`.
1123
+ presets = accept_global_flags(presets)