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,1522 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan new-som` -- scaffold the metadata for porting a brand-new SoM.
3
+
4
+ Port of `alp-sdk`'s `scripts/alp_cli/new_som.py`: the vendor-N+1 porting kit.
5
+ Generates the two metadata skeletons a new SoM port needs (the SoM preset
6
+ YAML and, when absent, the SoC spec JSON), with every schema-required
7
+ hardware-fact field present as an explicit TBD placeholder -- values are
8
+ NEVER invented -- and prints the numbered porting checklist.
9
+
10
+ Today's shipped `tan new-som` (`crates/tan-cli/src/commands/sdk_cli.rs`) is a
11
+ thin, stdio-inheriting forward to `python -m alp_cli new-som`. This file
12
+ becomes the real, in-process implementation; nothing here re-derives a
13
+ hardware fact the SDK's `metadata/**` already owns (ADR-0017, invariant
14
+ I-26) -- every SKU, part number, address or pin name in the generated
15
+ skeleton is either a caller-supplied argument or an explicit `TBD`.
16
+
17
+ Differences from the alp_cli original, and why:
18
+
19
+ * **`--format json` emits the envelope; the default text mode is the
20
+ original's, verbatim.** The original has no `--format`/`--json` flag, and
21
+ the Rust forwarder parses `--format` for this verb (clap `GlobalArgs`,
22
+ `global = true` -- confirmed live: `tan.exe new-som --format json
23
+ --sdk-root <bad>` reaches the SDK-root-unresolved failure, not a parse
24
+ error) without ever synthesising a `--json` for the child the way
25
+ `faultdecode`'s does (`sdk_cli.rs::build_argv`), so a successful run on
26
+ the oracle is plain text either way. This file used to match that split
27
+ exactly -- `--format` accepted and thrown away (`del ... output_format`),
28
+ zero `emit(` calls in the module. tan-cli#399 ends it: `contract/README.md`
29
+ states that the vscode extension drives `tan <cmd> --format json` and
30
+ hard-depends on `{command, ok, exitCode, project, data, issues}`, and a
31
+ verb that answers that flag with 1238 bytes of prose fails the consumer's
32
+ `isEnvelope` guard, which then reports the whole scaffold as the literal
33
+ string `Command completed.` with the planned file list unreachable. So
34
+ `--format json` now emits one envelope carrying the planned/written paths
35
+ and the porting checklist as `data`, and every refusal carries a
36
+ `new-som.failed` code instead of `cli.main`'s generic `cli.parse-error`.
37
+ WITHOUT `--format json` nothing changed: stdout carries the same
38
+ human-readable lines the original wrote there (skeleton validation notes,
39
+ `Created <path>`, the checklist), stderr the same error lines, matching
40
+ `click.echo(..., err=...)` verbatim -- which is what the oracle-parity
41
+ tests byte-compare.
42
+
43
+ * **`--sdk-root`/`--project` are new, and required.** The original ran
44
+ FROM WITHIN an alp-sdk checkout (`REPO_ROOT =
45
+ Path(__file__).resolve().parents[2]`) and never needed to ask where the
46
+ SDK was. `tan` is a separate, standalone binary, so this file resolves
47
+ the target checkout the same way every other metadata-facing command
48
+ does (`sdk_cmd.resolve_sdk_tiered`: `--sdk-root` > project pin > global
49
+ default > discovery) before doing anything else -- this is not a new
50
+ user-facing surface either: `tan new-som` already accepts `--sdk-root`
51
+ today, via the Rust forwarder's own `GlobalArgs`.
52
+
53
+ * **Interactive prompts use `click.prompt`/`click.Choice`, not
54
+ `questionary`.** The original's arrow-key select menus need a dependency
55
+ `tan` does not carry -- the package's runtime dependency set is a
56
+ deliberately short, enumerated list (`typer`, `rich`, `pyyaml`,
57
+ `jsonschema`; see `pyproject.toml`), and adding a fifth for one
58
+ command's menu widget is the opposite of that discipline. The SAME
59
+ questions are asked, in the same order, with the same defaults; only the
60
+ answer widget differs (type-and-choose instead of arrow-key-and-enter).
61
+
62
+ * **The SKU-prefix -> family-directory mapping is asked of the SDK, not
63
+ reproduced.** `_family_hw_revisions` cross-checks `--default-hw-rev`
64
+ against the new SKU's family's `hw-revisions.yaml`, which needs the
65
+ family directory (`aen`, `v2n`, ...) for a SKU prefix. That map
66
+ (`alp_project_loader._sku_family`) is hand-maintained and SDK-internal
67
+ with, by its own comment, "no second on-disk source" -- so it cannot be
68
+ read out of `metadata/**` the way every other fact in this file is, and
69
+ copying its dict into `tan` would be exactly the RFC #843 drift
70
+ (hand-porting SDK logic instead of consuming the SDK) this port exists
71
+ to avoid. `_resolve_sku_family` below instead asks the TARGET SDK's own
72
+ `alp_project_loader.py` for the answer via a short-lived subprocess,
73
+ matching the original's best-effort semantics (`None` on any failure --
74
+ unresolvable script, unrecognised prefix, or a brand-new family with no
75
+ mapping yet -- falls through to the "add this to the checklist"
76
+ branch, same as the original's `ImportError`-guarded import).
77
+
78
+ * **`--sku`'s help text (and the matching interactive prompt) say
79
+ "E1M-<UPPERCASE> shaped" instead of the oracle's "e.g. E1M-XYZ101"**
80
+ (`scripts/alp_cli/new_som.py:404`). Both describe the same `_SKU_RE`
81
+ pattern; the oracle's wording embeds a literal example SKU, and I-26's
82
+ hardware-fact gate (`tests/gates/test_no_new_hardware_facts.py`) would
83
+ then have to allowlist a string that names no real part just to keep the
84
+ wording verbatim, so this file describes the pattern shape instead.
85
+
86
+ `DEFAULT_BOARD` (`"E1M-EVK"`) is a UI default only, not a value this file
87
+ trusts: every accepted `--default-board` (including the unedited default)
88
+ is cross-checked against `metadata/boards/*.yaml`'s real `name:` values
89
+ before anything is rendered, so a stale literal here fails LOUD --
90
+ "default board 'E1M-EVK' does not match any `name:` in metadata/boards/"
91
+ -- rather than silently shipping a wrong one; see the allowlist entry in
92
+ `tests/gates/test_no_new_hardware_facts.py`.
93
+ """
94
+ from __future__ import annotations
95
+
96
+ import json
97
+ import os
98
+ import re
99
+ import sys
100
+ from pathlib import Path
101
+
102
+ import click
103
+ import typer
104
+
105
+ from tan.commands.doctor_cmd import probe
106
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
107
+ from tan.core.metadata_schema import schema_errors
108
+ from tan.core.new_som_ospi import is_alif_ensemble, render_ospi_memories_and_hyperram
109
+ from tan.core.sdk_discovery import (
110
+ _planner_python,
111
+ global_default_foreign_project_issue,
112
+ project_pin_issue,
113
+ resolve_sdk_tiered,
114
+ )
115
+ from tan.core.shapes import SDK_MARKER, matches_glob_suffix, rejected_sdk_root_message
116
+ from tan.env import stderr_is_tty, stdin_is_tty
117
+ from tan.envelope import Envelope, Issue, Project, emit
118
+ from tan.exit_codes import ExitCode
119
+ from tan.output_format import FORMAT_HELP, OutputFormat
120
+
121
+ _SKU_RE = re.compile(r"^E1M-[A-Z0-9-]+$")
122
+ _SOC_REF_RE = re.compile(r"^[a-z0-9-]+:[a-z0-9-]+:[a-z0-9-]+$")
123
+ _FAMILY_RE = re.compile(r"^[a-z][a-z0-9-]*$")
124
+ _CORE_ID_RE = re.compile(r"^[a-z][a-z0-9_]+$")
125
+ #: `som-preset-v2.schema.json`'s own `default_hw_rev` pattern, verbatim
126
+ #: (`metadata/schemas/som-preset-v2.schema.json` `properties.default_hw_rev
127
+ #: .pattern`). tan-cli#496 defect 5: this used to be checked ONLY by the
128
+ #: post-render schema self-check, which -- for a brand-new family, where the
129
+ #: `_family_hw_revisions` cross-check below is skipped -- reported a bad
130
+ #: value as `new-som.internal-failure` ("a tan bug, never bad user input"),
131
+ #: not the `new-som.failed` every other bad flag gets. Validated up front now
132
+ #: (`.fullmatch`, not `.match`: a trailing `$` still matches just before a
133
+ #: FINAL newline in non-MULTILINE mode, which is exactly the shape defect 3's
134
+ #: multi-line injection needs to slip past a `.match` check unnoticed).
135
+ _HW_REV_RE = re.compile(r"^[a-z0-9_-]+$")
136
+
137
+ #: Canonical backend keys already known to the device dispatcher, plus the
138
+ #: schema-legal lowercase `tbd` placeholder -- verbatim from the original.
139
+ INFERENCE_BACKENDS = ("ethos_u", "drpai", "deepx_dxm1", "tbd")
140
+ ETHOS_U_VARIANTS = ("u55", "u85")
141
+
142
+ #: `data.schemaVersion` for `--format json` (tan-cli#399). 1 because this is
143
+ #: the first shape `new-som` has ever put on the wire -- there is no earlier
144
+ #: one to be compatible with.
145
+ DATA_SCHEMA_VERSION = 1
146
+
147
+ DEFAULT_CORES = ("tbd_core0",)
148
+ #: UI default only -- see the module docstring and the gate allowlist entry.
149
+ DEFAULT_BOARD = "E1M-EVK"
150
+ DEFAULT_HW_REV = "r1"
151
+
152
+ #: `scripts/alp_project.py` is THE marker for an alp-sdk checkout (I-31).
153
+ #: `tan sdk switch` refuses in this build (tan-cli#305) -- kept the two
154
+ #: mechanisms that actually work here (`--sdk-root`, placing the project near
155
+ #: a checkout, both live tiers of `resolve_sdk_tiered`) and swapped the third
156
+ #: for `NO_SDK_NEXT_STEPS`'s honest "how to get one at all".
157
+ _SDK_ROOT_UNRESOLVED = (
158
+ "alp-sdk root is unresolved. Use --sdk-root, place the project near an "
159
+ f"alp-sdk checkout, or {NO_SDK_NEXT_STEPS}."
160
+ )
161
+
162
+
163
+ def _split_cores_csv(_ctx: click.Context, _param: click.Parameter, value: str | None):
164
+ if value is None:
165
+ return None
166
+ return tuple(s.strip() for s in value.split(",") if s.strip())
167
+
168
+
169
+ def _check_output_root(_ctx: click.Context, _param: click.Parameter, value: str | None) -> str | None:
170
+ """`click.Path(file_okay=False)` equivalent for `--output-root` -- the
171
+ oracle's own type (`scripts/alp_cli/new_som.py:432`). An existing
172
+ REGULAR FILE is rejected outright; a directory that does not exist yet
173
+ is fine (it is created later). Reproduced as an explicit check rather
174
+ than relied on falling out of a later `mkdir`/`write_text`, matching
175
+ the sibling `--elf`/`--file` checks in `faultdecode_cmd.py`
176
+ (`_check_elf_path`, `_check_file_path`)."""
177
+ if value is None:
178
+ return None
179
+ if Path(value).is_file():
180
+ raise typer.BadParameter(f"'{value}' is a file.", param_hint="'--output-root'")
181
+ return value
182
+
183
+
184
+ def _envelope(data, issues: list[Issue], exit_code: ExitCode) -> None:
185
+ """THE `new-som` envelope. `project` is hardcoded null/null on purpose:
186
+ this command scaffolds metadata INTO an alp-sdk checkout and never reads a
187
+ `board.yaml` -- `--project` is only one input tier to the SDK-root ladder,
188
+ so reporting a project root here would claim a resolution that never
189
+ happened (`envelope.Project.resolved`'s own "reporting is the seam" note).
190
+ The checkout that was actually scaffolded is `data.sdkRoot`."""
191
+ emit(Envelope("new-som", Project(root=None, board_yaml=None), data, issues, exit_code))
192
+
193
+
194
+ def _fail(message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAILURE, *,
195
+ json_mode: bool = False, extra_issues: list[Issue] | None = None) -> None:
196
+ """Refuse: one error to stderr (or one envelope under `--format json`) and
197
+ exit -- 1 by default, the flat exit code the alp_cli original's `_fail`
198
+ always used (`raise SystemExit(1)`) for every validation failure it can
199
+ raise (bad SKU, unknown board, ...); prefix reworded from `alp new-som:`
200
+ to `new-som:` (RFC #837: the binary is `tan`). `exit_code` overrides this
201
+ for the one failure this port adds that the original never had to: the
202
+ `--sdk-root`/`--project` resolution preflight below (the original always
203
+ ran FROM WITHIN a checkout). That check mirrors the Rust forwarder's own
204
+ preflight (`crates/tan-cli/src/commands/sdk_cli.rs::run`), which exits
205
+ `ExitCode::ValidationFailure` (2) for it specifically -- confirmed live:
206
+ `tan.exe new-som --sdk-root <bad>` exits 2, not 1 (every OTHER new-som
207
+ failure, including a bad-exit from the forwarded child, is RuntimeFailure
208
+ (1) there too, matching this default).
209
+
210
+ ONE code for every refusal, `new-som.failed`, matching the spelling the
211
+ v0.4.1 oracle's own forwarder emits for the identical class of refusal --
212
+ the alternative was a family of codes no consumer asked for, each of which
213
+ would then be a frozen wire string (tan-cli#399). Before this, a
214
+ `--format json` refusal reached `cli.main`'s generic fallback and went out
215
+ as `command: "cli"` / `cli.parse-error`, so the two paths of one command
216
+ disagreed about whose envelope a consumer was reading.
217
+
218
+ `data` is `{"subcommand": "new-som"}`, not `null`, matching the oracle
219
+ byte-for-byte -- measured live: `target/debug/tan.exe new-som --format
220
+ json --sdk-root <bad>` answers `data:{"subcommand":"new-som"}`, exit 2.
221
+ That is the SAME generic forwarder-preflight shape `sdk_cli.rs::run`
222
+ hands every `alp-*` forward it refuses before spawning a child (the
223
+ class faultdecode/model/monitor share), not something specific to any
224
+ one refusal reason here.
225
+
226
+ `extra_issues` (tan-cli#926) PREPENDS ahead of `new-som.failed`, the
227
+ `clean_cmd._run` shape; `None` at every other call site in this file."""
228
+ if json_mode:
229
+ _envelope(
230
+ {"subcommand": "new-som"},
231
+ [*(extra_issues or []), Issue("new-som.failed", "error", message)], exit_code,
232
+ )
233
+ else:
234
+ typer.echo(f"new-som: {message}", err=True)
235
+ raise typer.Exit(int(exit_code))
236
+
237
+
238
+ def _internal_error(subject: str, schema: str, errors: list[str], json_mode: bool) -> None:
239
+ """A generated skeleton that does not validate against its own schema is a
240
+ `tan` BUG, never bad user input -- reported as one, and never left to
241
+ `cli.main`'s parse-error fallback under `--format json` (tan-cli#399).
242
+ Shared by the preset and SoC-spec self-checks, which differ only in the
243
+ two names."""
244
+ message = (
245
+ f"INTERNAL ERROR -- generated {subject} does not validate against {schema}:"
246
+ )
247
+ if json_mode:
248
+ _envelope(
249
+ None,
250
+ [Issue("new-som.internal-failure", "error", " ".join([message, *errors]))],
251
+ ExitCode.RUNTIME_FAILURE,
252
+ )
253
+ else:
254
+ typer.echo(f"new-som: {message}", err=True)
255
+ for err in errors:
256
+ typer.echo(f" - {err}", err=True)
257
+ raise typer.Exit(int(ExitCode.RUNTIME_FAILURE))
258
+
259
+
260
+ def _has_control_char(value: str) -> bool:
261
+ r"""True if ``value`` carries a C0 control character (0x00-0x1F) or DEL
262
+ (0x7F) -- the full ASCII control set, not just C0.
263
+
264
+ tan-cli#496 round-2 major 2: DEL alone used to pass the narrower
265
+ `ord(ch) < 0x20` check every call site here used to write inline, reach
266
+ a rendered YAML double-quoted scalar unescaped (`_yaml_dquote` below
267
+ only escapes `\` and `"`), and crash the unguarded
268
+ `yaml.safe_load(preset_text)` self-check with a raw
269
+ `yaml.reader.ReaderError` -- exit 1, zero bytes on stdout under
270
+ `--format json` (measured: `--display-name $'X\x7fY'`). One shared
271
+ check closes it at every call site instead of widening each inline
272
+ comparison by hand.
273
+ """
274
+ return any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value)
275
+
276
+
277
+ def _yaml_dquote(value: str) -> str:
278
+ r"""Escape ``value`` for embedding in a YAML double-quoted scalar.
279
+
280
+ YAML double-quoted style uses C-like escapes, so `\` and `"` are the
281
+ only characters that need escaping here (control characters are
282
+ rejected up front by the command's validation, `_has_control_char`).
283
+ """
284
+ return value.replace("\\", "\\\\").replace('"', '\\"')
285
+
286
+
287
+ def _yaml_scalar(value: str) -> str:
288
+ """A single-line YAML scalar for ``value``, chosen by PyYAML's OWN
289
+ emitter -- plain, single- or double-quoted, whichever round-trips
290
+ exactly -- rather than a hand-rolled quoting rule.
291
+
292
+ tan-cli#496 defect 3: `_yaml_dquote` above is a SECOND, independently
293
+ maintained escaping implementation, and it only ever covered
294
+ `display_name`; `default_hw_rev`/`default_board` were spliced RAW, so
295
+ `--default-hw-rev $'r1\\ncapabilities: {secure_element: true}'` did not
296
+ need to defeat any quoting at all -- the literal newline in the raw
297
+ argument became a second YAML mapping-key line the instant it was
298
+ f-string-joined into the preset, planting a fabricated hardware
299
+ capability the schema self-check never sees (it only re-parses the
300
+ ALREADY-INJECTED document). Routing both fields through the real
301
+ emitter instead removes the second implementation, not just patches it.
302
+
303
+ Raises ``ValueError`` -- "must not contain newlines or other control
304
+ characters" -- for a ``value`` this cannot represent on one line; the
305
+ caller pre-refuses those before render time, so this is the backstop,
306
+ not the primary gate (`fail()`-worded messages belong at the call site,
307
+ which knows the flag name).
308
+
309
+ tan-cli#496 round-2 blocker 1: this used to call `yaml.safe_dump(value)`
310
+ at PyYAML's DEFAULT 80-column `best_width` and keep only
311
+ `.splitlines()[0]` -- fine for a value that fits in 80 columns, but
312
+ PyYAML folds anything longer at a space, so any `--default-board`/
313
+ `--default-hw-rev` past that width was SILENTLY TRUNCATED into the
314
+ generated preset and reported as a clean success (a SoM vendor
315
+ hardware fact, corrupted with no error). Blocker 2: at greater length
316
+ the fold can land INSIDE an emitted quoted scalar, so the truncated
317
+ text is not even valid YAML -- `new_som`'s unguarded
318
+ `yaml.safe_load(preset_text)` self-check then raised a raw
319
+ `yaml.scanner.ScannerError`, exit 1 with zero bytes on stdout under
320
+ `--format json` (measured: `--default-board "$(python3 -c "print('word
321
+ '*30)")"`). `width=float("inf")` tells the emitter never to fold at
322
+ all, so whatever style it picks always lands on ONE content line --
323
+ `dumped[0]` then keeps the full value, and any trailing lines are only
324
+ PyYAML's own `...` document-end marker, never truncated content.
325
+ """
326
+ if _has_control_char(value):
327
+ raise ValueError("must not contain newlines or other control characters")
328
+ # tan-cli#810: deferred, because `cli.py` static-imports every command
329
+ # module -- a module-scope `import yaml` here was paid by `tan --version`.
330
+ # `sys.modules` caches it, so this file's other three call sites cost a
331
+ # dict lookup. `tests/gates/test_cli_import_is_lean.py` holds the rule.
332
+ import yaml # noqa: PLC0415
333
+
334
+ dumped = yaml.safe_dump(value, width=float("inf")).splitlines()
335
+ return dumped[0] if dumped else "''"
336
+
337
+
338
+ def _som_schema_path(sdk_root: Path) -> Path:
339
+ return sdk_root / "metadata" / "schemas" / "som-preset-v2.schema.json"
340
+
341
+
342
+ def _soc_schema_path(sdk_root: Path) -> Path:
343
+ return sdk_root / "metadata" / "schemas" / "soc-spec-v1.schema.json"
344
+
345
+
346
+ def _current_sku_pattern(schema_path: Path) -> str:
347
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
348
+ return schema["properties"]["sku"]["pattern"]
349
+
350
+
351
+ def _is_yaml_board_file(name: str) -> bool:
352
+ """True if @name has a ``.yaml`` suffix -- case-sensitively on POSIX,
353
+ case-INSENSITIVELY on Windows, matching ``Path.glob``'s own
354
+ ``case_sensitive=None`` default (platform casing rules) exactly.
355
+
356
+ tan-cli#1127 review round 2: replacing ``boards_dir.glob("*.yaml")``
357
+ with ``os.listdir`` plus a plain ``entry.endswith(".yaml")`` would have
358
+ silently NARROWED the match on Windows -- a ``Foo.YAML`` board file
359
+ that ``glob`` used to enumerate there is invisible to a case-sensitive
360
+ suffix compare, dropping its ``name:`` from the known set and turning a
361
+ legitimate ``--default-board`` into a hard scaffold failure. This
362
+ exists so the listing helper below matches exactly what it replaced,
363
+ not a narrower set that happens to agree on POSIX. tan-cli#1132 hit the
364
+ identical question twice more (``*.conf``/``*.overlay``, ``*.json``), so
365
+ the RULE itself now lives once in ``shapes.matches_glob_suffix`` and this
366
+ is its ``.yaml`` application -- name, signature and contract unchanged;
367
+ only the place the casing decision is written moved.
368
+ """
369
+ return matches_glob_suffix(name, ".yaml")
370
+
371
+
372
+ def _known_board_names(sdk_root: Path) -> set[str] | None:
373
+ """Board ``name:`` values from ``<sdk_root>/metadata/boards/``, or
374
+ ``None`` when the directory is missing OR listable-but-empty/degenerate
375
+ (see the ``return names or None`` note below) -- the closed set of
376
+ shared carrier boards lives in the SDK checkout, not under
377
+ ``--output-root``.
378
+
379
+ tan-cli#1116 review round 2/3 replaced a ``boards_dir.is_dir()``
380
+ pre-flight with ``boards_dir.glob("*.yaml")`` wrapped in one ``except
381
+ OSError``. That primitive choice was itself only ACCIDENTALLY correct,
382
+ tan-cli#1127 found: ``Path.glob`` raises ``PermissionError`` for a
383
+ permission-denied parent OR grandparent directory on 3.12.3, so the
384
+ ``except OSError`` here was genuinely load-bearing THERE -- but on
385
+ 3.13.15 AND 3.14.7 ``glob`` instead returns an EMPTY iterator for the
386
+ identical shape, silently (measured; driven against real
387
+ ``python-build-standalone`` 3.13.15/3.14.7 builds plus this project's
388
+ pinned 3.12.3). On those two interpreters the ``except OSError`` clause
389
+ never fired at all, and this function still answered ``None`` only
390
+ because ``return names or None`` below happens to treat "found nothing"
391
+ and "permission denied" as the same outcome. A refactor touching only
392
+ that return line (``return names or None`` -> ``return names``, which
393
+ reads as a harmless tidy-up) would have silently reintroduced the
394
+ escaping-``PermissionError`` defect on 3.13/3.14 while staying green on
395
+ 3.12 -- nothing at the call site would have said why.
396
+
397
+ tan-cli#1127 makes the correctness deliberate: the listing now goes
398
+ through ``os.listdir``, which raises ``OSError`` (``PermissionError``
399
+ included) for a permission-denied ancestor -- parent or grandparent --
400
+ on all three measured interpreters, unlike ``Path.glob``. The ``except
401
+ OSError`` below is genuinely load-bearing on every supported
402
+ interpreter now, not only 3.12; nothing about this function's
403
+ permission-handling remains interpreter-dependent.
404
+
405
+ Review round 2 (PR #1129) caught a second, independent way the
406
+ primitive swap could have silently changed behaviour: ``Path.glob``'s
407
+ default ``case_sensitive=None`` matches by PLATFORM casing rules --
408
+ case-sensitive on POSIX, case-INSENSITIVE on Windows -- so
409
+ ``boards_dir.glob("*.yaml")`` used to enumerate a ``Foo.YAML`` on
410
+ Windows, and a plain ``entry.endswith(".yaml")`` over ``os.listdir``
411
+ would not (measured identically on all three interpreters run on this
412
+ POSIX box: ``glob("*.yaml")`` -> ``['lower.yaml']``,
413
+ ``glob("*.yaml", case_sensitive=False)`` -> ``['lower.yaml',
414
+ 'upper.YAML']``, a plain ``endswith(".yaml")`` -> ``['lower.yaml']`` only).
415
+ ``_is_yaml_board_file`` above preserves that Windows matching exactly,
416
+ by delegating to ``shapes.matches_glob_suffix`` (tan-cli#1132, where
417
+ the case-fold moved) -- this function's contract is "list what
418
+ `Path.glob("*.yaml")` used to list, correctly, on every interpreter",
419
+ not "list what a case-sensitive suffix compare finds".
420
+ """
421
+ boards_dir = sdk_root / "metadata" / "boards"
422
+ import yaml # noqa: PLC0415 -- deferred, see `_yaml_scalar` (tan-cli#810)
423
+
424
+ try:
425
+ entries = os.listdir(boards_dir)
426
+ except OSError:
427
+ return None
428
+ candidates = sorted(
429
+ boards_dir / entry for entry in entries if _is_yaml_board_file(entry)
430
+ )
431
+ names: set[str] = set()
432
+ for path in candidates:
433
+ try:
434
+ doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
435
+ except (OSError, yaml.YAMLError, UnicodeDecodeError):
436
+ # tan-cli#415: `UnicodeDecodeError` is a `ValueError`, not a
437
+ # `yaml.YAMLError`, so a non-UTF-8 board file escaped this
438
+ # best-effort scan as an unhandled traceback instead of being
439
+ # skipped the same way an unparseable one already is.
440
+ #
441
+ # tan-cli#1116 review round 2: `OSError` is caught too -- a file
442
+ # this listing enumerated but cannot itself be READ (a per-file
443
+ # `chmod 000`, distinct from the containing-directory failure
444
+ # the `except OSError` around `os.listdir` above already
445
+ # covers) raised a raw `PermissionError` past this same
446
+ # best-effort scan (measured escaping before this fix).
447
+ continue
448
+ if isinstance(doc, dict) and isinstance(doc.get("name"), str):
449
+ names.add(doc["name"])
450
+ # tan-cli#1127: still doing real work, not a vestige of the retired
451
+ # `Path.glob` accident. `os.listdir` above already turns "denied
452
+ # ancestor" into an explicit `None` via `except OSError`; this line's
453
+ # remaining job is coalescing a LISTABLE-BUT-EMPTY result (no `.yaml`
454
+ # files at all, or every one present failed to parse / carried no
455
+ # `name:`) into that same "not resolvable" `None` -- the caller
456
+ # (`board_names is not None and default_board not in board_names`)
457
+ # treats `None` as "can't verify, don't gate on it" and would otherwise
458
+ # hard-fail every scaffold against an empty `known:` list for a
459
+ # directory that is present but genuinely has nothing in it yet.
460
+ return names or None
461
+
462
+
463
+ def _resolve_sku_family(sku: str, sdk_root: Path) -> str | None:
464
+ """The SKU's family DIRECTORY name (``aen``, ``v2n``, ...), resolved by
465
+ asking the TARGET SDK's own ``alp_project_loader._sku_family`` -- never
466
+ reproduced in `tan` (I-26; see the module docstring). Best-effort: any
467
+ failure (script missing, unrecognised prefix, broken interpreter) is
468
+ ``None``, matching the original's `ImportError`-guarded import.
469
+ """
470
+ script = sdk_root / "scripts" / "alp_project_loader.py"
471
+ if not script.is_file():
472
+ return None
473
+ out = probe(
474
+ [
475
+ _planner_python(str(sdk_root), str(sdk_root)),
476
+ "-c",
477
+ "import sys; sys.path.insert(0, sys.argv[1]); "
478
+ "from alp_project_loader import _sku_family; "
479
+ "print(_sku_family(sys.argv[2]))",
480
+ os.path.abspath(sdk_root / "scripts"), # probe runs from an empty cwd
481
+ sku,
482
+ ]
483
+ )
484
+ if out is None:
485
+ return None
486
+ family_dir = out.strip()
487
+ return family_dir or None
488
+
489
+
490
+ def _family_hw_revisions(
491
+ sku: str, output_root: Path, sdk_root: Path
492
+ ) -> tuple[Path, set[str]] | None:
493
+ """(path, rev keys) of the SKU family's hw-revisions.yaml, or None.
494
+
495
+ None means "not resolvable at scaffold time" -- a brand-new family with
496
+ no SKU-prefix mapping / no hw-revisions file yet (creating one is a
497
+ porting-checklist step).
498
+
499
+ tan-cli#1116 review round 2/3: NOT a ``path.is_file()`` pre-flight --
500
+ that used to decide whether to try THIS root's candidate or fall
501
+ through to the next one, and it raised a raw ``PermissionError`` for a
502
+ permission-denied ancestor directory on 3.12.3 AND 3.13.15 (measured
503
+ escaping before this fix on both) -- NOT "every supported interpreter"
504
+ as an earlier draft of this note claimed: on 3.14.7 ``is_file()``
505
+ itself already returns ``False`` for that same shape (the same
506
+ swallowed-errno version skew the other round-2 fixes in this PR
507
+ describe; `is_file()` is not exempt from it after all), so the OLD
508
+ pre-flight would have silently taken the wrong branch there too -- not
509
+ a crash, but a permission failure on THIS root silently treated as
510
+ "not here, try the next root" instead of the genuine "found it, but
511
+ it's broken" this function's own docstring below distinguishes.
512
+ Reading straight through instead:
513
+ ``FileNotFoundError``/``IsADirectoryError``/``NotADirectoryError`` all
514
+ mean "not usable AT THIS ROOT", so they fall through to the next
515
+ candidate exactly as a ``False`` ``is_file()`` used to; any OTHER
516
+ failure (permission, a non-UTF-8 file, invalid YAML) means the file IS
517
+ there and IS the one for this SKU, so it stops the search with ``None``
518
+ rather than silently trying a second root that was never going to be
519
+ the right answer.
520
+
521
+ tan-cli#1127 re-checked this function against the ``_known_board_names``
522
+ finding next door and found it does NOT need the same
523
+ ``Path.glob``-to-``os.listdir`` swap: this function never calls
524
+ ``Path.glob`` (or any other directory-listing primitive) in the first
525
+ place -- it reads ``path.read_text()`` directly, with no stat-based
526
+ pre-flight, which is exactly the "read straight through" shape the
527
+ paragraph above already describes. ``Path.read_text()`` raises
528
+ ``PermissionError`` for a permission-denied ancestor identically on
529
+ 3.12.3, 3.13.15 and 3.14.7 (measured, both for the immediate parent and
530
+ a grandparent directory denied) -- unlike ``Path.glob``/``Path.is_dir()``/
531
+ ``Path.is_file()``, plain reads were never subject to that version skew.
532
+ This function's permission-handling was ALREADY deliberate, not
533
+ accidental, before tan-cli#1127; nothing here changed.
534
+ """
535
+ family_dir = _resolve_sku_family(sku, sdk_root)
536
+ if family_dir is None:
537
+ return None
538
+ import yaml # noqa: PLC0415 -- deferred, see `_yaml_scalar` (tan-cli#810)
539
+
540
+ for root in (output_root, sdk_root):
541
+ path = root / "metadata" / "e1m_modules" / family_dir / "hw-revisions.yaml"
542
+ try:
543
+ doc = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
544
+ except (FileNotFoundError, IsADirectoryError, NotADirectoryError):
545
+ continue
546
+ except (OSError, yaml.YAMLError, UnicodeDecodeError):
547
+ # tan-cli#415: `UnicodeDecodeError` is caught alongside
548
+ # `yaml.YAMLError` -- it is a `ValueError`, not a
549
+ # `yaml.YAMLError`, and previously escaped uncaught rather than
550
+ # resolving to "not resolvable". `OSError` (tan-cli#1116 review
551
+ # round 2) covers a per-file `chmod 000`, distinct from the
552
+ # ancestor-directory case the `except` above now handles by
553
+ # falling through instead of stopping.
554
+ return None
555
+ revs = doc.get("hw_revisions")
556
+ if isinstance(revs, dict):
557
+ return path, {str(k) for k in revs}
558
+ return None
559
+ return None
560
+
561
+
562
+ def _interactive(
563
+ sku: str | None,
564
+ soc_ref: str | None,
565
+ family: str | None,
566
+ vendor: str | None,
567
+ display_name: str | None,
568
+ inference_backend: str | None,
569
+ ethos_u_variant: str | None,
570
+ cores: tuple[str, ...] | None,
571
+ default_board: str | None,
572
+ default_hw_rev: str | None,
573
+ ):
574
+ """Ask the same questions, in the same order, the original's
575
+ `questionary`-based `_interactive` did -- see the module docstring for
576
+ why the answer widget is `click.prompt`/`click.Choice` here instead.
577
+
578
+ Every prompt below is `err=True`: `click.prompt`'s OWN default is
579
+ `err=False` (the question goes to stdout), which is backwards for a
580
+ command whose consent gate (`tan.core.consent.can_prompt`,
581
+ `_interactive`'s only caller checks stdin+STDERR) already promises the
582
+ question rides stderr and the answer rides stdin -- stdout stays free
583
+ for whatever the invocation is piping or redirecting (tan-cli#496
584
+ defect 6). Without this, `tan new-som > log.txt` with a real terminal
585
+ on stdin/stderr writes the question into `log.txt` instead of the
586
+ terminal and blocks on a question the human never saw.
587
+ """
588
+ if sku is None:
589
+ sku = click.prompt("New SoM SKU (E1M-<UPPERCASE> shaped)", err=True).strip()
590
+ if soc_ref is None:
591
+ soc_ref = click.prompt(
592
+ "Silicon triple-colon ref (vendor:family:part, e.g. nxp:imx9:imx95)", err=True
593
+ ).strip()
594
+ if family is None:
595
+ family = click.prompt(
596
+ "Human-readable family slug (e.g. nxp-imx9)", err=True
597
+ ).strip()
598
+ if vendor is None:
599
+ vendor = click.prompt(
600
+ "Vendor display name for the SoC JSON",
601
+ default=soc_ref.split(":")[0] if _SOC_REF_RE.match(soc_ref) else "",
602
+ err=True,
603
+ ).strip()
604
+ if display_name is None:
605
+ display_name = click.prompt(
606
+ "Display name",
607
+ default=f"{sku} ({vendor} -- scaffold, silicon facts TBD)",
608
+ err=True,
609
+ ).strip()
610
+ if inference_backend is None:
611
+ inference_backend = click.prompt(
612
+ "Inference backend (silicon-determined; pick `tbd` when unknown)",
613
+ type=click.Choice(INFERENCE_BACKENDS),
614
+ default="tbd",
615
+ err=True,
616
+ )
617
+ if inference_backend == "ethos_u" and ethos_u_variant is None:
618
+ ethos_u_variant = click.prompt(
619
+ "Primary Ethos-U variant",
620
+ type=click.Choice(ETHOS_U_VARIANTS),
621
+ err=True,
622
+ )
623
+ if cores is None:
624
+ answer = click.prompt(
625
+ "Canonical core ids, comma-separated (leave the tbd placeholder "
626
+ "when unknown)",
627
+ default=",".join(DEFAULT_CORES),
628
+ err=True,
629
+ )
630
+ cores = tuple(s.strip() for s in answer.split(",") if s.strip())
631
+ if default_board is None:
632
+ default_board = click.prompt(
633
+ "Default board (stock carrier)", default=DEFAULT_BOARD, err=True
634
+ ).strip()
635
+ if default_hw_rev is None:
636
+ default_hw_rev = click.prompt(
637
+ "Default hw rev", default=DEFAULT_HW_REV, err=True
638
+ ).strip()
639
+ return (
640
+ sku,
641
+ soc_ref,
642
+ family,
643
+ vendor,
644
+ display_name,
645
+ inference_backend,
646
+ ethos_u_variant,
647
+ cores,
648
+ default_board,
649
+ default_hw_rev,
650
+ )
651
+
652
+
653
+ def _render_preset(
654
+ sku: str,
655
+ soc_ref: str,
656
+ family: str,
657
+ display_name: str,
658
+ inference_backend: str,
659
+ ethos_u_variant: str | None,
660
+ cores: tuple[str, ...],
661
+ default_board: str,
662
+ default_hw_rev: str,
663
+ ) -> str:
664
+ vendor_slug, family_slug, part_slug = soc_ref.split(":")
665
+ soc_rel = f"metadata/socs/{vendor_slug}/{family_slug}/{part_slug}.json"
666
+
667
+ lines: list[str] = []
668
+ a = lines.append
669
+ a(f"# Stock preset skeleton for {sku}, generated by `tan new-som`.")
670
+ a("#")
671
+ a("# Every value below marked TBD awaits the authoritative hardware")
672
+ a("# config (datasheet / schematic / BOM). Fill facts in from the")
673
+ a("# primary source named next to each field -- NEVER guess (see")
674
+ a("# docs/porting-new-som.md).")
675
+ a("")
676
+ a("schema_version: 2")
677
+ a("")
678
+ a("# SKU is assigned by Alp Lab product planning. A brand-new family")
679
+ a("# also needs one alternation added to the `sku:` pattern in")
680
+ a("# metadata/schemas/som-preset-v2.schema.json (porting guide step 3).")
681
+ a(f"sku: {sku}")
682
+ a("")
683
+ a("# Human-readable family slug (NOT the directory name; the SKU-prefix")
684
+ a("# -> family-directory map lives in scripts/alp_project.py).")
685
+ a(f"family: {family}")
686
+ a("")
687
+ a(f"# Triple-colon silicon ref; must resolve to {soc_rel}.")
688
+ a(f"silicon: {soc_ref}")
689
+ a("")
690
+ a("# Vendor order code from the datasheet's Ordering Information table;")
691
+ a(f"# must match a variants[].order_code in {soc_rel}.")
692
+ a("# While TBD, the loader falls back to variants[].alp_module_skus")
693
+ a("# reverse lookup.")
694
+ a("silicon_variant: TBD")
695
+ a("")
696
+ a(f'display_name: "{_yaml_dquote(display_name)}"')
697
+ a("")
698
+ a("# Populated on-module chips -- the module BOM / schematic is")
699
+ a("# authoritative. Chip values are slugs matching a")
700
+ a("# metadata/chips/<chip>.yaml manifest. The legal role keys are the")
701
+ a("# CLOSED set in som-preset-v2.schema.json `on_module:` (pmic_main,")
702
+ a("# pmic_secondary, clock_generator, rtc_external, temperature_sensor,")
703
+ a("# eeprom, secure_element, wifi_ble, supervisor_mcu, ethernet_phy,")
704
+ a("# nor_flash, emmc, npu, pcie_mux, ospi_memories, hyperram,")
705
+ a("# i2c_devices). Delete the TBD rows for roles this module does NOT")
706
+ a("# populate; add rows for the ones it does.")
707
+ a("on_module:")
708
+ a(f" silicon: {soc_ref}")
709
+ a(" pmic_main: TBD # main PMIC part (BOM)")
710
+ a(" eeprom: TBD # identity EEPROM part (BOM)")
711
+ if is_alif_ensemble(vendor_slug, family_slug):
712
+ lines.extend(render_ospi_memories_and_hyperram())
713
+ a(" # i2c_devices: # per-bus I2C device tables;")
714
+ a(" # <bus_name>: # bus names are a per-family")
715
+ a(" # bus_master: TBD # fact (schematic). Chip")
716
+ a(" # devices: # slugs + 7-bit addresses come")
717
+ a(' # - { chip: <slug>, role: <role>, address_7bit: "TBD" }')
718
+ a("")
719
+ a("# Off-SoC module memory in the canonical cross-family shape.")
720
+ a("# Authoritative source: the module BOM (DRAM + flash parts).")
721
+ a("# On-die SRAM/MRAM is derived from the SoC variant, never declared here.")
722
+ a("memory:")
723
+ a(" dram_mbit: TBD # external volatile memory, Mbit")
724
+ a(" flash_mbit: TBD # external non-volatile memory, Mbit")
725
+ a("")
726
+ a("# Inference-accelerator selection -- silicon-determined (SoC")
727
+ a("# datasheet), never customer-facing. `auto_order` is the ordered")
728
+ a("# ALP_INFERENCE_BACKEND_AUTO preference, best first; its first entry")
729
+ a("# IS the preferred backend, and `cpu` (the floor), when listed, goes")
730
+ a("# last. `tbd` is the schema-legal placeholder backend;")
731
+ a("# scripts/check_inference_backend_parity.py cross-checks every entry")
732
+ a("# against the device dispatcher and accepts `tbd` ONLY while")
733
+ a("# `status.preliminary:` below is true, so this scaffold commits green")
734
+ a("# as-is. Replace `tbd` with the real canonical keys (ethos_u, drpai,")
735
+ a("# deepx_dxm1, ..., then cpu) before clearing the preliminary flag.")
736
+ a("inference:")
737
+ a(f" auto_order: [{inference_backend}]")
738
+ if inference_backend == "ethos_u":
739
+ # Primary variant only. Which Ethos-U instances the part carries is
740
+ # silicon-determined -- the SDK derives it from the SoC JSON npus[] /
741
+ # capabilities.ethos_uNN_count -- so the preset does NOT enumerate them
742
+ # (the removed `npu_population` field is never scaffolded).
743
+ a(f" ethos_u_variant: {ethos_u_variant}")
744
+ a("")
745
+ a("# capabilities: -- OPTIONAL, omitted in the skeleton. Declare ONLY")
746
+ a("# keys the SoM ADDS on top of the silicon capabilities (e.g. an")
747
+ a("# on-module secure element or bridge accelerator); silicon caps come")
748
+ a(f"# from {soc_rel} `capabilities:`.")
749
+ a("")
750
+ a("# Per-core default runtime + app mapping. Keys MUST match cores[].id")
751
+ a(f"# in {soc_rel} (cross-checked by pr-metadata-validate). Rename any")
752
+ a("# tbd placeholder id in lockstep with the SoC JSON, then fill each")
753
+ a("# entry: `machine:` + `toolchain:` for Yocto A-class cores, `board:`")
754
+ a("# + `toolchain:` for Zephyr M-class cores (see E1M-AEN801.yaml /")
755
+ a("# E1M-V2N102.yaml for the two shapes).")
756
+ a("topology:")
757
+ for core in cores:
758
+ a(f" {core}: {{}}")
759
+ a("")
760
+ a("# Memory layout (SRAM banks + on-die flash) is derived from the SoC")
761
+ a("# variant resolved via `silicon_variant:` -- see the SoC JSON")
762
+ a("# `variants[].sram_banks_kb`. Declare a memory_map: block here ONLY")
763
+ a("# for non-stock partitioning. Every row you author MUST carry")
764
+ a("# `write_authority:` (REQUIRED since som-preset v2; the schema")
765
+ a("# rejects a row without it). It records WHO may write the region,")
766
+ a("# and when: customer_image (written only by the flash tool),")
767
+ a("# vendor_image (factory-provisioned), customer_runtime (writable by")
768
+ a("# the application; the ONLY value an IPC carve-out or runtime mount")
769
+ a("# may land on), secure_enclave (written by the Secure Enclave at")
770
+ a("# provisioning), none (an explicit no-writer, not a \"not sure yet\"")
771
+ a("# catch-all), composite (a whole-device alias spanning rows of")
772
+ a("# different authority). Absent means unresolved, never")
773
+ a("# customer_runtime. See docs/porting-new-som.md.")
774
+ a("")
775
+ a("# Vendor mailbox / IPC controller -- the vendor reference manual /")
776
+ a("# hand-written HW config is authoritative. The channel reservations")
777
+ a("# below are the SDK-standard convention shared by every released")
778
+ a("# preset; keep them unless the controller has fewer channels.")
779
+ a("mailbox:")
780
+ a(" controller: TBD")
781
+ a(" channels:")
782
+ a(" - { id: 0, reserved_for: alp_default_rpmsg }")
783
+ a(" - { id: 1, reserved_for: app }")
784
+ a(" - { id: 2, reserved_for: app }")
785
+ a(" - { id: 3, reserved_for: power_mgmt }")
786
+ a("")
787
+ a("# pad_routes: -- OPTIONAL, omitted in the skeleton. Once the")
788
+ a("# schematic is available, either (a) list every E1M pad that routes")
789
+ a("# through an on-module mediator chip (see E1M-V2N102.yaml), (b)")
790
+ a("# declare an explicit empty list to assert \"no mediator, everything")
791
+ a("# is SoC-direct\", or (c) list pads with")
792
+ a("# `dispatch: TBD` while routing is pending. Omitted, the loader")
793
+ a("# treats every pad as SoC-direct -- resolve this before clearing")
794
+ a("# status.partial_hw_config.")
795
+ a("")
796
+ a("# helper_firmware: -- OPTIONAL, omitted in the skeleton. One entry")
797
+ a("# per independently-flashed on-module helper MCU image.")
798
+ a("# Omit when the module has none.")
799
+ a("")
800
+ a("# Must resolve against metadata/e1m_modules/<family-dir>/hw-revisions.yaml.")
801
+ a(f"default_hw_rev: {_yaml_scalar(default_hw_rev)}")
802
+ a("")
803
+ a("# Stock carrier board this SoM ships on (see metadata/boards/).")
804
+ a(f"default_board: {_yaml_scalar(default_board)}")
805
+ a("")
806
+ a("# Keep both flags true until every TBD above is resolved from the")
807
+ a("# authoritative HW config. `preliminary: true` is also the marker")
808
+ a("# that lets the parity gate accept the `tbd` inference backend.")
809
+ a("status:")
810
+ a(" preliminary: true")
811
+ a(" partial_hw_config: true")
812
+ a("")
813
+ return "\n".join(lines)
814
+
815
+
816
+ def _soc_skeleton(sku: str, soc_ref: str, vendor: str, cores: tuple[str, ...]) -> dict:
817
+ _, family_slug, part_slug = soc_ref.split(":")
818
+ core_rows = []
819
+ for core in cores:
820
+ # `count: 1` is the schema minimum, NOT a datasheet fact -- the
821
+ # notes[] entry below flags it (JSON has no comments; this file
822
+ # uses the schema-sanctioned `_pending_reason` + `notes` fields).
823
+ core_rows.append({"id": core, "type": "TBD", "count": 1})
824
+ return {
825
+ "soc_spec_version": 1,
826
+ "ref": soc_ref,
827
+ "vendor": vendor,
828
+ "family": family_slug,
829
+ "part": part_slug,
830
+ "status": "preliminary",
831
+ "pending_reference_manual_ingestion": True,
832
+ "_pending_reason": (
833
+ "Scaffolded by `tan new-som`: every silicon fact in this file "
834
+ "is a placeholder pending datasheet / reference-manual "
835
+ "ingestion. peripherals: {} means unknown, not zero."
836
+ ),
837
+ "cores": core_rows,
838
+ "npus": [],
839
+ "peripherals": {},
840
+ "variants": [
841
+ {
842
+ "order_code": "TBD",
843
+ "notes": (
844
+ "Placeholder row: replace order_code with the vendor's "
845
+ "Ordering Information part number, then mirror it in the "
846
+ "SoM preset's silicon_variant."
847
+ ),
848
+ "alp_module_skus": [sku],
849
+ }
850
+ ],
851
+ "notes": [
852
+ "JSON has no comments -- scaffold TODOs live in this notes[] "
853
+ "array plus _pending_reason (both schema-sanctioned fields).",
854
+ "cores[]: the tbd id / type TBD / count 1 rows are "
855
+ "schema-minimum placeholders, NOT datasheet facts; rename the "
856
+ "ids in lockstep with the SoM preset's topology: keys.",
857
+ "vendor/family/part hold the ref slugs until the datasheet "
858
+ "display names are filled in.",
859
+ ],
860
+ }
861
+
862
+
863
+ # tan-cli#964: the validator itself moved to `tan.core.metadata_schema` --
864
+ # every READ consumer (`tan build`, `tan generate`, `tan presets`, `tan
865
+ # size`, `tan bootstrap`) needed the SAME `jsonschema.Draft202012Validator`
866
+ # call this file already had for its WRITE-path self-check, and a second
867
+ # hand-rolled copy is exactly the per-consumer-guard drift #964 is about.
868
+ # `_schema_errors` is kept as the local name (both call sites below are
869
+ # unchanged) and still means exactly what it always has here: `source=None`,
870
+ # so a validation failure is reported against the in-memory generated
871
+ # skeleton (`pointer: message`), not a file on disk -- this scaffold has not
872
+ # been written yet when either call below runs.
873
+ _schema_errors = schema_errors
874
+
875
+
876
+ def _rollback_write_failure(
877
+ preset_path: Path,
878
+ preset_existed_before: bool,
879
+ preset_backup: bytes | None,
880
+ preset_write_attempted: bool,
881
+ soc_path: Path,
882
+ soc_doc,
883
+ ) -> list[str]:
884
+ """Undo whatever THIS invocation actually touched after a write failure,
885
+ and report exactly what could not be undone -- never more. Returns a
886
+ description string per candidate that could not be undone; an empty
887
+ list means the rollback is clean.
888
+
889
+ Three tan-cli#496 fixes live here:
890
+
891
+ * **Defect 2.** `preset_path` is RESTORED to its original bytes when it
892
+ pre-existed (a `--force` re-scaffold whose SoC-spec write then
893
+ failed), never deleted -- a destructive op must not remove a good,
894
+ pre-existing artifact before its replacement is proven good. A
895
+ brand-new preset (did not exist before this run) is still removed;
896
+ there is nothing of the customer's to lose there.
897
+ * **The finding against #496's own fix.** `soc_path` is removed only
898
+ when it PHYSICALLY EXISTS -- `Path.exists()` swallows `OSError`
899
+ (including the `NotADirectoryError` a regular file below
900
+ `--output-root` raises), unlike `unlink(missing_ok=True)`, which only
901
+ swallows `FileNotFoundError` and lets that one through. The issue's
902
+ own primary repro (a regular file at `<output-root>/metadata/socs`)
903
+ fails at `mkdir()` before `soc_path` is ever created, so `exists()`
904
+ is False and nothing is attempted -- the old code attempted the
905
+ unlink anyway, got a second `NotADirectoryError`, and told the
906
+ caller "this may be a half-written scaffold" for a path that was
907
+ never written at all.
908
+ * **tan-cli#496 round-2 blocker 3.** The preset branch below is gated
909
+ on `preset_write_attempted`, a flag the caller sets the instant the
910
+ preset write is ABOUT to start -- not on membership in a `written`
911
+ list the caller only appended to AFTER `write_text` returned
912
+ successfully. `Path.write_text` opens the file (truncating any
913
+ pre-existing content) and issues one `write()` call; an `OSError`
914
+ from THAT call (`ENOSPC`/`EDQUOT`/`EFBIG`/`EIO`) leaves a physically
915
+ created, partially-written file on disk but raises before the
916
+ caller's `written.append(preset_path)` ever ran. Gating on `written`
917
+ alone treated that file as never touched by this run and left the
918
+ partial write unrolled-back; `preset_write_attempted` covers that
919
+ case too, on top of the "wrote fine, the SoC-spec write after it
920
+ failed" case a `written` check alone already caught.
921
+ """
922
+ cleanup_failures: list[str] = []
923
+ if preset_write_attempted:
924
+ if preset_existed_before:
925
+ if preset_backup is None:
926
+ cleanup_failures.append(
927
+ f"{preset_path} (no backup was captured; its content "
928
+ "cannot be safely restored -- left as written by this run)"
929
+ )
930
+ else:
931
+ try:
932
+ preset_path.write_bytes(preset_backup)
933
+ except OSError as exc:
934
+ cleanup_failures.append(
935
+ f"{preset_path} (could not restore its original content: {exc})"
936
+ )
937
+ else:
938
+ try:
939
+ if preset_path.exists():
940
+ preset_path.unlink()
941
+ except OSError as exc:
942
+ cleanup_failures.append(f"{preset_path} ({exc})")
943
+ if soc_doc is not None:
944
+ try:
945
+ if soc_path.exists():
946
+ soc_path.unlink()
947
+ except OSError as exc:
948
+ cleanup_failures.append(f"{soc_path} ({exc})")
949
+ return cleanup_failures
950
+
951
+
952
+ def new_som(
953
+ sku: str = typer.Option(
954
+ None,
955
+ "--sku",
956
+ # tan-cli#720: `--som` accepted as an alias, matching `tan init`'s
957
+ # spelling of the same value. The refusal message below keeps naming
958
+ # `--sku`, the canonical form for this command.
959
+ "--som",
960
+ help="New SoM SKU (E1M-<UPPERCASE> shaped) (alias: --som).",
961
+ ),
962
+ soc_ref: str = typer.Option(
963
+ None, "--soc-ref", help="Silicon triple-colon ref, e.g. nxp:imx9:imx95."
964
+ ),
965
+ family: str = typer.Option(
966
+ None, "--family", help="Human-readable family slug, e.g. nxp-imx9."
967
+ ),
968
+ vendor: str = typer.Option(
969
+ None,
970
+ "--vendor",
971
+ help="Vendor display name for the SoC JSON (default: the soc-ref vendor segment).",
972
+ ),
973
+ display_name: str = typer.Option(
974
+ None, "--display-name", help="Preset display_name (default: derived from the SKU)."
975
+ ),
976
+ inference_backend: str = typer.Option(
977
+ None,
978
+ "--inference-backend",
979
+ help="Silicon-determined inference backend "
980
+ f"({'/'.join(INFERENCE_BACKENDS)}; default: tbd).",
981
+ ),
982
+ ethos_u_variant: str = typer.Option(
983
+ None,
984
+ "--ethos-u-variant",
985
+ help="Primary Ethos-U variant "
986
+ f"({'/'.join(ETHOS_U_VARIANTS)}); required with --inference-backend ethos_u.",
987
+ ),
988
+ cores: str = typer.Option(
989
+ None,
990
+ "--cores",
991
+ callback=_split_cores_csv,
992
+ help="Comma-separated canonical core ids (default: tbd_core0).",
993
+ ),
994
+ default_board: str = typer.Option(
995
+ None,
996
+ "--default-board",
997
+ help="Stock carrier board (a `name:` from metadata/boards/; "
998
+ "default: the SDK's stock development-board carrier).",
999
+ ),
1000
+ default_hw_rev: str = typer.Option(
1001
+ None, "--default-hw-rev", help="Default hardware revision (default: r1)."
1002
+ ),
1003
+ output_root: str = typer.Option(
1004
+ None,
1005
+ "--output-root",
1006
+ metavar="PATH",
1007
+ callback=_check_output_root,
1008
+ help="Root to generate metadata/ under (default: the SDK checkout).",
1009
+ ),
1010
+ dry_run: bool = typer.Option(
1011
+ False, "--dry-run", help="Validate and print the planned files; write nothing."
1012
+ ),
1013
+ force: bool = typer.Option(
1014
+ False, "--force", help="Overwrite an existing preset for this SKU."
1015
+ ),
1016
+ project: str = typer.Option(
1017
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1018
+ ),
1019
+ sdk_root: str = typer.Option(
1020
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1021
+ ),
1022
+ board_yaml: str = typer.Option(None, "--board-yaml", hidden=True),
1023
+ target: str = typer.Option(None, "--target", hidden=True),
1024
+ all_targets: bool = typer.Option(False, "--all", hidden=True),
1025
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
1026
+ verbose: bool = typer.Option(False, "--verbose", hidden=True),
1027
+ quiet: bool = typer.Option(False, "--quiet", hidden=True),
1028
+ no_color: bool = typer.Option(False, "--no-color", hidden=True),
1029
+ non_interactive: bool = typer.Option(False, "--non-interactive", hidden=True),
1030
+ ci: bool = typer.Option(False, "--ci", hidden=True),
1031
+ ) -> None:
1032
+ """Scaffold the metadata skeletons for porting a new SoM."""
1033
+ # The nine options above are clap's `GlobalArgs` members (`global = true`)
1034
+ # that the oracle accepts on EVERY verb, `new-som` included, and never
1035
+ # reads for this one -- declared here purely so the argv SURFACE matches:
1036
+ # `tan new-som --ci ...` exits the same as the same invocation without
1037
+ # `--ci` on the oracle; without this, it was a Click "No such option"
1038
+ # usage error (exit 2) instead. `--format` is no longer one of them: it
1039
+ # was accepted-and-deleted here until tan-cli#399, which is what made a
1040
+ # `--format json` scaffold print prose at the one consumer that parses
1041
+ # this flag -- see the docstring bullet above.
1042
+ del board_yaml, target, all_targets
1043
+ del verbose, quiet, no_color, non_interactive, ci
1044
+ resolved_format = output_format or OutputFormat.TEXT
1045
+ json_mode = resolved_format == "json"
1046
+
1047
+ report_lines: list[str] = []
1048
+
1049
+ def say(line: str = "") -> None:
1050
+ """One line of the human report. Suppressed under `--format json`,
1051
+ where stdout carries the ONE envelope and nothing else -- the same
1052
+ lines survive there as `data.report`."""
1053
+ report_lines.append(line)
1054
+ if not json_mode:
1055
+ typer.echo(line)
1056
+
1057
+ def fail(message: str, exit_code: ExitCode = ExitCode.RUNTIME_FAILURE, *,
1058
+ extra_issues: list[Issue] | None = None) -> None:
1059
+ """`_fail` with this run's output mode bound, so no refusal site has to
1060
+ remember to pass it -- a forgotten one would silently print prose to
1061
+ stdout under `--format json` and leave the envelope to `cli.main`."""
1062
+ _fail(message, exit_code, json_mode=json_mode, extra_issues=extra_issues)
1063
+
1064
+ # Mirrors the original's `type=click.Choice(...)` flag-level validation --
1065
+ # a Click-usage error (exit 2) BEFORE anything else runs, same as the
1066
+ # original raised it during argument parsing itself. Written as an
1067
+ # explicit check rather than `click_type=click.Choice(...)` on the
1068
+ # `typer.Option` above: that form crashes with an uncaught `StopIteration`
1069
+ # under this repo's pinned typer/click pair instead of the clean
1070
+ # `BadParameter` every other command in this package raises by hand (see
1071
+ # `sdk_cmd.sdk`'s `--format` check for the established pattern).
1072
+ if inference_backend is not None and inference_backend not in INFERENCE_BACKENDS:
1073
+ raise typer.BadParameter(
1074
+ f"{inference_backend!r} is not one of "
1075
+ f"{', '.join(repr(c) for c in INFERENCE_BACKENDS)}.",
1076
+ param_hint="'--inference-backend'",
1077
+ )
1078
+ if ethos_u_variant is not None and ethos_u_variant not in ETHOS_U_VARIANTS:
1079
+ raise typer.BadParameter(
1080
+ f"{ethos_u_variant!r} is not one of "
1081
+ f"{', '.join(repr(c) for c in ETHOS_U_VARIANTS)}.",
1082
+ param_hint="'--ethos-u-variant'",
1083
+ )
1084
+
1085
+ workspace_root = Path.cwd() / project if project else Path.cwd()
1086
+ active = resolve_sdk_tiered(sdk_root, workspace_root)
1087
+ # tan-cli#926: computed before the `.path is None` refusal below (tan-cli#900 fix).
1088
+ pin_issue = project_pin_issue(active.broken_project_pin, active.tier)
1089
+ foreign_issue = global_default_foreign_project_issue(active.foreign_global_default_for)
1090
+ resolution_issues = [i for i in (pin_issue, foreign_issue) if i is not None]
1091
+ if not json_mode:
1092
+ for issue in resolution_issues:
1093
+ typer.echo(f"new-som: warning: {issue.message}", err=True)
1094
+ if active.path is None or not Path(active.path).joinpath(*SDK_MARKER).exists():
1095
+ # tan-cli#497 defect 7: a REJECTED `--sdk-root` names the value.
1096
+ # `resolve_sdk_tiered` is TERMINAL on the flag (I-31) and returns it
1097
+ # verbatim even when invalid, so the marker check right above is the
1098
+ # ONLY thing that knows the path was rejected -- and this refusal
1099
+ # carries no `sdk` block, so the typed value left no trace at all while
1100
+ # the message told the caller to "Use --sdk-root".
1101
+ fail(
1102
+ rejected_sdk_root_message(sdk_root, "No SoM scaffold was written.")
1103
+ if sdk_root
1104
+ else _SDK_ROOT_UNRESOLVED,
1105
+ ExitCode.VALIDATION_FAILURE,
1106
+ extra_issues=resolution_issues,
1107
+ )
1108
+ return
1109
+ resolved_sdk = Path(active.path)
1110
+ # tan-cli#263 review: this command WRITES metadata skeletons into
1111
+ # `resolved_sdk` -- a silently-missed `.alp/sdk-path` pin means porting a
1112
+ # new SoM into the wrong checkout. Under `--format json` it rides the
1113
+ # envelope's `issues` as the already-frozen `sdk.project-pin-unresolved`
1114
+ # (tan-cli#399); in text mode it stays the stderr line it always was.
1115
+ # tan-cli#464: same reasoning -- this command writes metadata skeletons
1116
+ # into `resolved_sdk`, and a `globalDefault` answer a DIFFERENT project's
1117
+ # bootstrap relocation actually decided is exactly as dangerous here as a
1118
+ # silently-missed project pin.
1119
+ issues: list[Issue] = list(resolution_issues)
1120
+
1121
+ # -- 1. Gather inputs. Interactive prompts need a real terminal; in a
1122
+ # pipe / CI, fail fast naming exactly what is missing instead of an
1123
+ # opaque prompt-library abort.
1124
+ if sku is None or soc_ref is None or family is None:
1125
+ missing = [
1126
+ flag
1127
+ for flag, value in (("--sku", sku), ("--soc-ref", soc_ref), ("--family", family))
1128
+ if value is None
1129
+ ]
1130
+ # `sys.stdin`/`sys.stderr` are bare references, not guaranteed
1131
+ # streams. Two distinct failures, and a guard has to cover both:
1132
+ # either can be `None` under a console-less launcher, and either can
1133
+ # be an object that EXISTS but has no `.isatty()` -- `cli.py`'s own
1134
+ # `_TeeStderr` under `--format json` is exactly that shape. A
1135
+ # hand-rolled `X is None or not X.isatty()` catches the first and
1136
+ # raises `AttributeError` on the second.
1137
+ #
1138
+ # tan-cli#496 round-2 major 1: BOTH streams are checked, not only
1139
+ # `stdin`. `_interactive`'s prompts are `err=True` (defect 6 above)
1140
+ # -- the question rides stderr, the answer rides stdin -- so a
1141
+ # redirected/piped stderr with a real stdin terminal has no way to
1142
+ # show the human the question, and the old stdin-only gate admitted
1143
+ # exactly that shape, blocking on a prompt nobody could see.
1144
+ #
1145
+ # tan-cli#488 round 5: both operands route through
1146
+ # `tan.env.stdin_is_tty`/`stderr_is_tty` -- the one shared probe
1147
+ # (tan-cli#288) that catches `AttributeError` (replaced stream) and
1148
+ # `ValueError` (closed stream) alike -- rather than repeating the
1149
+ # hand-rolled shape a sixth time. `tan.core.consent.can_prompt`
1150
+ # carries the same pair for the same reason.
1151
+ if not (stdin_is_tty() and stderr_is_tty()):
1152
+ fail(
1153
+ "stdin or stderr is not a terminal, so interactive prompts "
1154
+ "are unavailable; pass the missing required flag(s): "
1155
+ + ", ".join(missing)
1156
+ )
1157
+ return
1158
+ if json_mode:
1159
+ # A prompt writes to stdout, which under `--format json` carries
1160
+ # the one envelope -- prompting there would corrupt the very
1161
+ # document the caller asked for, on a terminal that happens to be
1162
+ # attached (tan-cli#399).
1163
+ fail(
1164
+ "--format json makes stdout the envelope channel, so "
1165
+ "interactive prompts are unavailable; pass the missing "
1166
+ "required flag(s): " + ", ".join(missing)
1167
+ )
1168
+ return
1169
+ (
1170
+ sku,
1171
+ soc_ref,
1172
+ family,
1173
+ vendor,
1174
+ display_name,
1175
+ inference_backend,
1176
+ ethos_u_variant,
1177
+ cores,
1178
+ default_board,
1179
+ default_hw_rev,
1180
+ ) = _interactive(
1181
+ sku,
1182
+ soc_ref,
1183
+ family,
1184
+ vendor,
1185
+ display_name,
1186
+ inference_backend,
1187
+ ethos_u_variant,
1188
+ cores,
1189
+ default_board,
1190
+ default_hw_rev,
1191
+ )
1192
+
1193
+ # Flag-driven (CI) defaults for everything not provided.
1194
+ if inference_backend is None:
1195
+ inference_backend = "tbd"
1196
+ if cores is None:
1197
+ cores = DEFAULT_CORES
1198
+ if default_board is None:
1199
+ default_board = DEFAULT_BOARD
1200
+ if default_hw_rev is None:
1201
+ default_hw_rev = DEFAULT_HW_REV
1202
+ output_root_path = Path(output_root) if output_root else resolved_sdk
1203
+
1204
+ # -- 2. Validate EVERYTHING before touching the filesystem, so a
1205
+ # rejected invocation never leaves half-written skeletons behind.
1206
+ if not _SKU_RE.match(sku):
1207
+ fail(f"SKU '{sku}' must look like E1M-<UPPERCASE>")
1208
+ return
1209
+ if not _SOC_REF_RE.match(soc_ref):
1210
+ fail(f"soc-ref '{soc_ref}' must be <vendor>:<family>:<part> (lowercase slugs)")
1211
+ return
1212
+ if not _FAMILY_RE.match(family):
1213
+ fail(f"family '{family}' must be a lowercase slug")
1214
+ return
1215
+ if not cores:
1216
+ fail(
1217
+ "--cores was given but contains no core ids "
1218
+ "(omit the flag for the tbd_core0 placeholder)"
1219
+ )
1220
+ return
1221
+ bad_cores = [c for c in cores if not _CORE_ID_RE.match(c)]
1222
+ if bad_cores:
1223
+ fail(f"core id(s) {bad_cores} must match ^[a-z][a-z0-9_]+$")
1224
+ return
1225
+ # tan-cli#496 defect 4: a repeated `--cores` id used to reach both
1226
+ # skeletons -- a duplicate `<id>: {}` mapping key in the preset's
1227
+ # `topology:` block (invalid YAML; PyYAML keeps the last, a strict
1228
+ # loader like alp-sdk's `validate_metadata.py` rejects it outright) and
1229
+ # two identical `cores[]` rows in the SoC JSON -- reported as a clean
1230
+ # success by both schema self-checks, since neither schema forbids a
1231
+ # repeated id within its own document. `tan init`'s sibling parser
1232
+ # (`tan.core.scaffold.parse_cores`) already refuses this; new-som didn't.
1233
+ dup_cores = sorted({c for c in cores if cores.count(c) > 1})
1234
+ if dup_cores:
1235
+ fail(f"--cores has duplicate id(s) {dup_cores}; every core id must be unique")
1236
+ return
1237
+ if inference_backend == "ethos_u" and ethos_u_variant is None:
1238
+ fail("--inference-backend ethos_u requires --ethos-u-variant (u55/u85)")
1239
+ return
1240
+ if vendor is None:
1241
+ vendor = soc_ref.split(":")[0]
1242
+ if display_name is None:
1243
+ display_name = f"{sku} ({vendor} -- scaffold, silicon facts TBD)"
1244
+ if _has_control_char(display_name):
1245
+ fail("display name must not contain newlines or other control characters")
1246
+ return
1247
+ # tan-cli#496 defect 3/5: `default_hw_rev` used to reach the RENDERED
1248
+ # preset unchecked by this command itself -- only the post-render schema
1249
+ # self-check saw it, and only after the value had already been spliced
1250
+ # into YAML text. A single-line-but-out-of-pattern value (`R1`) then
1251
+ # surfaced as `new-som.internal-failure` for a brand-new family (defect
1252
+ # 5: the `_family_hw_revisions` cross-check below is skipped there, the
1253
+ # one place that would otherwise have caught it as `new-som.failed`
1254
+ # first) -- a caller mistake misreported as a tan bug. A MULTI-LINE
1255
+ # value defeated the self-check entirely (defect 3): the injected lines
1256
+ # became sibling YAML keys, so `default_hw_rev` itself still read back
1257
+ # pattern-clean. `.fullmatch`, not `.match`+`$`: see `_HW_REV_RE`'s
1258
+ # comment for why `$` alone is not equivalent here.
1259
+ if not _HW_REV_RE.fullmatch(default_hw_rev):
1260
+ fail(f"default hw rev '{default_hw_rev}' must match {_HW_REV_RE.pattern!r}")
1261
+ return
1262
+ # `default_board` has no schema pattern (a bare `type: string`, since a
1263
+ # real carrier name may contain spaces/hyphens the SKU-shaped fields
1264
+ # don't), so the injection above is closed by refusing control
1265
+ # characters -- the one thing a single-line preset value can never
1266
+ # legitimately need -- rather than by a charset it does not have.
1267
+ if _has_control_char(default_board):
1268
+ fail("default board must not contain newlines or other control characters")
1269
+ return
1270
+
1271
+ # Resolve the cross-references the checklist used to defer: the stock
1272
+ # carrier must exist in metadata/boards/, and the hw rev must resolve in
1273
+ # the family hw-revisions file when that file exists (a brand-new family
1274
+ # has none yet -- that stays a checklist step).
1275
+ board_names = _known_board_names(resolved_sdk)
1276
+ if board_names is not None and default_board not in board_names:
1277
+ fail(
1278
+ f"default board '{default_board}' does not match any `name:` "
1279
+ f"in metadata/boards/ (known: {', '.join(sorted(board_names))})"
1280
+ )
1281
+ return
1282
+ hw_revs = _family_hw_revisions(sku, output_root_path, resolved_sdk)
1283
+ if hw_revs is not None:
1284
+ hwrev_path, revs = hw_revs
1285
+ if default_hw_rev not in revs:
1286
+ fail(
1287
+ f"default hw rev '{default_hw_rev}' does not resolve in "
1288
+ f"{hwrev_path} (known: {', '.join(sorted(revs))})"
1289
+ )
1290
+ return
1291
+
1292
+ preset_path = output_root_path / "metadata" / "e1m_modules" / f"{sku}.yaml"
1293
+ vendor_slug, family_slug, part_slug = soc_ref.split(":")
1294
+ soc_path = (
1295
+ output_root_path / "metadata" / "socs" / vendor_slug / family_slug / f"{part_slug}.json"
1296
+ )
1297
+
1298
+ if preset_path.exists() and not force:
1299
+ fail(f"{preset_path} already exists (pass --force to overwrite)")
1300
+ return
1301
+
1302
+ # -- 3. Render + self-check both skeletons in memory (still nothing on
1303
+ # disk): the skeletons must be schema-valid on arrival. The one
1304
+ # sanctioned exception is a SKU outside the schema's current pattern --
1305
+ # extending that pattern IS a porting step (see below).
1306
+ try:
1307
+ preset_text = _render_preset(
1308
+ sku,
1309
+ soc_ref,
1310
+ family,
1311
+ display_name,
1312
+ inference_backend,
1313
+ ethos_u_variant,
1314
+ cores,
1315
+ default_board,
1316
+ default_hw_rev,
1317
+ )
1318
+ except ValueError as exc:
1319
+ # Backstop for `_yaml_scalar`, not the primary gate: the checks above
1320
+ # already refuse every value that would land here. Never reached in
1321
+ # a correct build; kept so a future field spliced in without its own
1322
+ # upfront check fails loud (`new-som.failed`) instead of an unhandled
1323
+ # traceback with zero bytes on stdout under `--format json`.
1324
+ fail(f"could not render the preset skeleton: {exc}")
1325
+ return
1326
+ soc_doc = None if soc_path.exists() else _soc_skeleton(sku, soc_ref, vendor, cores)
1327
+
1328
+ som_schema_path = _som_schema_path(resolved_sdk)
1329
+ soc_schema_path = _soc_schema_path(resolved_sdk)
1330
+ try:
1331
+ sku_needs_pattern = re.match(_current_sku_pattern(som_schema_path), sku) is None
1332
+ except (OSError, UnicodeDecodeError) as exc:
1333
+ # tan-cli#415: `som-preset-v2.schema.json` is SDK-supplied rather than
1334
+ # user-supplied, but an unreadable or non-UTF-8 copy must still reach a
1335
+ # coded envelope -- never a bare traceback with zero bytes on stdout.
1336
+ # `UnicodeDecodeError` is a `ValueError`, not an `OSError`, so it has to
1337
+ # be named explicitly beside it.
1338
+ fail(f"could not read {som_schema_path} ({exc})")
1339
+ return
1340
+ import yaml # noqa: PLC0415 -- deferred, see `_yaml_scalar` (tan-cli#810)
1341
+
1342
+ try:
1343
+ preset_doc = yaml.safe_load(preset_text)
1344
+ except yaml.YAMLError as exc:
1345
+ # tan-cli#496 round-2 major 2: this was unguarded -- a value that
1346
+ # slipped past every upfront check above (the DEL byte
1347
+ # `_has_control_char` now rejects; a hypothetical future field
1348
+ # spliced into `_render_preset` without its own check) reached the
1349
+ # rendered document and could still make it unparseable, raising a
1350
+ # raw `yaml.reader.ReaderError`/`yaml.scanner.ScannerError` -- exit
1351
+ # 1, zero bytes on stdout under `--format json`. Backstop, not the
1352
+ # primary gate, same shape as the `_render_preset` `ValueError`
1353
+ # catch just above.
1354
+ fail(f"could not parse the rendered preset skeleton: {exc}")
1355
+ return
1356
+ if preset_doc is not None:
1357
+ try:
1358
+ errors = _schema_errors(preset_doc, som_schema_path)
1359
+ except (OSError, UnicodeDecodeError) as exc:
1360
+ fail(f"could not read {som_schema_path} ({exc})")
1361
+ return
1362
+ if sku_needs_pattern:
1363
+ errors = [e for e in errors if not e.startswith("sku:")]
1364
+ if errors:
1365
+ _internal_error("preset", "som-preset-v2", errors, json_mode)
1366
+ say(
1367
+ "Preset skeleton validates against som-preset-v2"
1368
+ + (" (except the sku pattern -- see step below)" if sku_needs_pattern else "")
1369
+ )
1370
+ if soc_doc is not None:
1371
+ try:
1372
+ errors = _schema_errors(soc_doc, soc_schema_path)
1373
+ except (OSError, UnicodeDecodeError) as exc:
1374
+ fail(f"could not read {soc_schema_path} ({exc})")
1375
+ return
1376
+ if errors:
1377
+ _internal_error("SoC spec", "soc-spec-v1", errors, json_mode)
1378
+ say("SoC spec skeleton validates against soc-spec-v1")
1379
+
1380
+ # -- 4. Write (or, under --dry-run, only report) the skeletons.
1381
+ #
1382
+ # `planned` is every path this run creates or would create, and `existing`
1383
+ # every one it deliberately left alone -- the two lists the extension's
1384
+ # scaffold panel renders (tan-cli#399), built from the same values the
1385
+ # human lines are built from so the two can never disagree.
1386
+ planned: list[Path] = [preset_path] + ([soc_path] if soc_doc is not None else [])
1387
+ existing: list[Path] = [] if soc_doc is not None else [soc_path]
1388
+ written: list[Path] = []
1389
+ # tan-cli#496 defect 2: captured BEFORE any write touches `preset_path`,
1390
+ # so a `--force` re-scaffold whose SoC-spec write later fails can
1391
+ # restore the customer's original preset instead of deleting it (see
1392
+ # `_rollback_write_failure`). `None` when the preset is brand new --
1393
+ # there was nothing to back up, and nothing must be restored either.
1394
+ preset_existed_before = preset_path.exists()
1395
+ preset_backup: bytes | None = None
1396
+ # tan-cli#496 round-2 blocker 3: set the instant the write is ABOUT to
1397
+ # start, not after it succeeds -- `written.append(preset_path)` below
1398
+ # only runs once `write_text` has already returned, so gating the
1399
+ # rollback on `preset_path in written` alone missed a write that
1400
+ # started, physically created/truncated the file, and THEN raised
1401
+ # (ENOSPC/EDQUOT/EFBIG/EIO) partway through. See
1402
+ # `_rollback_write_failure`'s docstring.
1403
+ preset_write_attempted = False
1404
+ if dry_run:
1405
+ say(f"Would create {preset_path}")
1406
+ if soc_doc is None:
1407
+ say(f"SoC spec already present: {soc_path} (not touched)")
1408
+ else:
1409
+ say(f"Would create {soc_path}")
1410
+ else:
1411
+ try:
1412
+ preset_path.parent.mkdir(parents=True, exist_ok=True)
1413
+ if preset_existed_before:
1414
+ preset_backup = preset_path.read_bytes()
1415
+ preset_write_attempted = True
1416
+ preset_path.write_text(preset_text, encoding="utf-8", newline="\n")
1417
+ written.append(preset_path)
1418
+ if soc_doc is not None:
1419
+ soc_path.parent.mkdir(parents=True, exist_ok=True)
1420
+ soc_path.write_text(
1421
+ json.dumps(soc_doc, indent=2) + "\n", encoding="utf-8", newline="\n"
1422
+ )
1423
+ written.append(soc_path)
1424
+ except OSError as exc:
1425
+ # Never leave a half-written scaffold behind, and never destroy
1426
+ # a pre-existing one either -- `_rollback_write_failure` is both
1427
+ # halves of tan-cli#496 (defect 2's restore-not-delete and the
1428
+ # finding against this file's OWN earlier fix: only a path that
1429
+ # PHYSICALLY EXISTS is ever reported as a cleanup failure, so a
1430
+ # target this run never reached disk for is never called
1431
+ # "may be half-written") plus round-2 blocker 3's
1432
+ # attempted-not-just-succeeded tracking.
1433
+ cleanup_failures = _rollback_write_failure(
1434
+ preset_path,
1435
+ preset_existed_before,
1436
+ preset_backup,
1437
+ preset_write_attempted,
1438
+ soc_path,
1439
+ soc_doc,
1440
+ )
1441
+ if cleanup_failures:
1442
+ fail(
1443
+ f"could not write the skeletons ({exc}); cleanup also "
1444
+ "failed, so this may be a half-written scaffold -- "
1445
+ "could not remove: " + "; ".join(cleanup_failures)
1446
+ )
1447
+ else:
1448
+ # "Rolled back", not "removed": a --force re-scaffold whose
1449
+ # preset pre-existed was RESTORED, not deleted (defect 2).
1450
+ fail(f"could not write the skeletons ({exc}); rolled back any partial output")
1451
+ return
1452
+ say(f"Created {preset_path}")
1453
+ if soc_doc is None:
1454
+ say(f"SoC spec already present: {soc_path} (not touched)")
1455
+ else:
1456
+ say(f"Created {soc_path}")
1457
+
1458
+ # -- 5. Numbered next-steps checklist.
1459
+ soc_created = soc_doc is not None
1460
+ steps = [
1461
+ f"Fill every TBD in {preset_path.name}"
1462
+ + (f" and {soc_path.name}" if soc_created else "")
1463
+ + " from the authoritative datasheet / schematic / BOM"
1464
+ " -- never invent values.",
1465
+ ]
1466
+ if sku_needs_pattern:
1467
+ steps.append(
1468
+ f"Extend the `sku:` pattern in "
1469
+ f"metadata/schemas/som-preset-v2.schema.json to accept {sku} "
1470
+ f"(docs/porting-new-som.md, schema-pattern step)."
1471
+ )
1472
+ steps.append(
1473
+ f'Register the silicon ref: add "{soc_ref}" to '
1474
+ f"metadata/registries/silicon-kconfig.json and the matching "
1475
+ f"ALP_SOC_* stanza in zephyr/Kconfig."
1476
+ )
1477
+ if hw_revs is None:
1478
+ # Only when the family hw-revisions file could not be resolved at
1479
+ # scaffold time (brand-new family); otherwise the rev was already
1480
+ # validated above.
1481
+ steps.append(
1482
+ f"Ensure default_hw_rev '{default_hw_rev}' resolves in "
1483
+ f"metadata/e1m_modules/<family-dir>/hw-revisions.yaml "
1484
+ f"(add the family file for a brand-new family)."
1485
+ )
1486
+ steps += [
1487
+ "Validate all metadata: python scripts/validate_metadata.py",
1488
+ "Regenerate derived headers: python scripts/gen_soc_caps.py "
1489
+ "&& python scripts/gen_board_header.py",
1490
+ "Run the conformance suite: tests/zephyr/conformance via twister (native_sim).",
1491
+ "Full walkthrough: docs/porting-new-som.md",
1492
+ ]
1493
+ say("")
1494
+ say("Next steps:")
1495
+ for i, step in enumerate(steps, start=1):
1496
+ say(f" {i}. {step}")
1497
+ if dry_run:
1498
+ say("")
1499
+ say("Dry run -- validated OK, nothing was written.")
1500
+
1501
+ if json_mode:
1502
+ # The success envelope, and the whole point of tan-cli#399: a consumer
1503
+ # that parsed stdout on the failure path got 1238 bytes of prose here.
1504
+ # Paths are the native spelling `data.sdkPath` uses elsewhere -- only
1505
+ # the envelope's own `sdk.root` is normalised to forward slashes.
1506
+ _envelope(
1507
+ {
1508
+ "schemaVersion": DATA_SCHEMA_VERSION,
1509
+ "sku": sku,
1510
+ "socRef": soc_ref,
1511
+ "sdkRoot": str(resolved_sdk),
1512
+ "outputRoot": str(output_root_path),
1513
+ "dryRun": dry_run,
1514
+ "planned": [str(p) for p in planned],
1515
+ "written": [str(p) for p in written],
1516
+ "existing": [str(p) for p in existing],
1517
+ "nextSteps": steps,
1518
+ "report": report_lines,
1519
+ },
1520
+ issues,
1521
+ ExitCode.SUCCESS,
1522
+ )