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,940 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan diff` -- show how `normalize_board_model` changes the parsed
3
+ board.yaml (tan-cli#260).
4
+
5
+ Mirrors `crates/tan-cli/src/commands/diff.rs` plus the two `tan-core` helpers
6
+ it composes (`model::{parse_board_model, normalize_board_model}`,
7
+ `diff::{collect_diff_entries, prune_nulls}`).
8
+
9
+ **Why this is NOT a generic recursive JSON differ, unlike the Rust.** The Rust
10
+ parses the WHOLE `board.yaml` into a typed `BoardModel`, normalizes it, and
11
+ diffs the two full trees with a generic recursive walk
12
+ (`tan_core::diff::collect_recursive`). `normalize_board_model`
13
+ (`crates/tan-core/src/model.rs:217-237`) only ever CLEARS four top-level
14
+ fields, and only ever to `None`/absent -- it never adds a key and never
15
+ changes one that survives:
16
+
17
+ * schema version < 2: `libraries` if it deserialized to an EMPTY list, `iot`
18
+ if none of its four toggles is `true`, `inference` if both its fields are
19
+ empty/absent.
20
+ * schema version >= 2: `os` unconditionally (v2 moves it into `cores:`).
21
+
22
+ Every other known field (`som`, `preset`, `cores`, `ipc`, `diagnostics`,
23
+ `populated`, `chips`, `e1m_routes`) is IDENTICAL between the parsed and
24
+ normalized model, so a full recursive diff would recurse into each, find
25
+ `before == after`, and contribute zero entries -- the same outcome this
26
+ module reaches directly, without building or comparing either side's full
27
+ tree. A `DiffEntry.kind` is therefore always `"removed"` here; `"added"`/
28
+ `"changed"` are unreachable through `normalize_board_model` and are kept only
29
+ so the wire shape (`DiffKind`: `added`/`removed`/`changed`) stays the
30
+ contract's, not because this module can produce them today.
31
+
32
+ **PyYAML is required.** Unlike the shallow top-level-shape scanners in
33
+ `validate_cmd`/`presets_cmd` (scalar-vs-block only), computing this diff needs
34
+ real nested values -- is `iot:` a mapping, is any of its four toggles `true`,
35
+ is `inference:`'s `backend` empty -- which a line-oriented fallback cannot
36
+ answer. tan ships no YAML dependency of its own (`typer` + `rich` only), so a
37
+ build with no PyYAML installed refuses with `diff.pyyaml-unavailable`
38
+ (`RUNTIME_FAILURE`, matching `validate_cmd`'s `spawn-not-implemented`
39
+ precedent for "this build cannot do that yet") rather than guessing.
40
+
41
+ **YAML 1.1 vs 1.2 boolean literals.** PyYAML's default `SafeLoader` resolves
42
+ YAML 1.1's full loose bool vocabulary (`on`/`off`/`yes`/`no`/`y`/`n`, any
43
+ case) to `bool`; `serde_yaml` (YAML 1.2 core schema) resolves only the six
44
+ canonical `true`/`True`/`TRUE`/`false`/`False`/`FALSE` spellings and leaves
45
+ everything else a plain string -- measured against the oracle:
46
+ `schemaVersion: 1` + `os: on` is `changes: [{"path":"libraries",...}]` at exit
47
+ 0 there (`os` is a `String` field, resolved to the literal text `"on"`,
48
+ untouched at schema version 1). The stock loader instead hands `_parse_fields`
49
+ a Python `bool` for `os` -- and even after `_string_scalar_field` stopped
50
+ refusing an out-of-`str` scalar outright (tan-cli#570), a `bool` there still
51
+ coerces to the CANONICAL text `"true"`, not the original spelling `"on"`,
52
+ which would silently corrupt the diff's `before` value rather than merely
53
+ refuse it. `_load_document` therefore still parses with `_Yaml12BoolLoader`, a
54
+ `SafeLoader` subclass with the YAML 1.1 bool resolver's `on`/`off`/`yes`/`no`/
55
+ `y`/`n` patterns removed, rather than plain `yaml.safe_load`, so those
56
+ spellings reach `_parse_fields` as the plain strings `serde_yaml` would
57
+ produce in the first place.
58
+
59
+ **Scope of the structural checks below.** `_parse_fields` validates the
60
+ TOP-LEVEL type of every known `BoardModel` field (is `cores:` a mapping, is
61
+ `ipc:` a list, ...) because a real Rust type mismatch anywhere in the document
62
+ fails the WHOLE typed parse (`ParseError::Yaml`), and reporting success with a
63
+ wrong diff would be a worse defect than an over-eager refusal. It does NOT
64
+ validate the shape of values `diff` never reads (`cores.*.peripherals`,
65
+ `e1m_routes.*`, ...) -- those fields are never touched by
66
+ `normalize_board_model` and never enter this module's output; going a level
67
+ deeper than "top-level key has the right YAML kind" would just be more parser
68
+ tan does not need for the one question this command answers.
69
+
70
+ `iot`'s four toggles and `inference.backend`/`inference.default_arena_kib` are
71
+ the one exception: they gate `compute_diff_entries`'s pruning decision
72
+ directly, so an unchecked wrong type there does not just under-refuse -- it
73
+ mis-classifies pruning and fabricates a diff entry the oracle never emits
74
+ (measured: `iot: {wifi: "yes"}` used to report `ok:true` with a manufactured
75
+ `{"path":"iot","kind":"removed",...}` entry; the oracle is exit 2
76
+ `diff.schema-violation`). `iot`'s four toggles must each be `bool` or absent.
77
+ `inference.default_arena_kib` must be a non-negative integer (`u32` range) or
78
+ absent. `inference.backend` is a `String` field checked the same way
79
+ `_string_scalar_field` now checks `os`/`preset` (tan-cli#570) -- only the
80
+ compound shapes (`list`/`dict`) no `String` field can ever hold are rejected;
81
+ any other scalar PyYAML resolves it to (even a bare `5` or `true`) is accepted
82
+ as non-empty, exactly as the oracle's own String-field coercion treats it
83
+ (measured: `inference: {backend: 5}` is `unchanged: true` at exit 0 on the
84
+ oracle, never pruned) -- only `_inference_is_empty`'s stringification needed
85
+ fixing to stop miscounting a non-`str` truthy `backend` as blank.
86
+
87
+ `som:`'s own shape (must be a mapping, not a bare SKU string) is checked with
88
+ the exact oracle wording via Python's own `repr()` -- which is actually the
89
+ *more* correct implementation of the two: the Rust's `python_repr` hand-mimics
90
+ Python's `repr()` from a `serde_yaml` (YAML 1.2) value and has one known gap
91
+ against real Python semantics on YAML 1.1-vs-1.2 boolean/null resolution
92
+ (`tan_core::validate` module docs); this module calls `repr()` on a value
93
+ `_Yaml12BoolLoader` (YAML 1.2's narrower bool vocabulary, the same rules
94
+ `serde_yaml` uses) actually parsed, so there is nothing left to approximate.
95
+
96
+ **The SDK cross-check (tan-cli#455).** Everything above only asks whether
97
+ `diff` can PARSE the document well enough to normalize it -- a real type at
98
+ each field it reads. It has no notion of the SDK's own semantic rules (a SoM
99
+ SKU pattern, whether a preset actually exists, the closed `peripherals:`
100
+ enum) -- those live in `metadata/schemas/board.schema.json` and are enforced
101
+ only by the SDK's own `scripts/validate_board_yaml.py`, which `validate`
102
+ spawns and this module, until now, never did. That let a board.yaml
103
+ `validate` refuses outright (e.g. an unknown SoM SKU, a nonexistent preset, an
104
+ unlisted peripheral token -- four real `jsonschema` violations, measured)
105
+ still reach `compute_diff_entries` and report a clean, meaningless
106
+ `unchanged: true` -- the same file, the same project, two commands
107
+ disagreeing about whether it is usable at all. Fixed by REUSING `validate`'s
108
+ own spawn-and-analyze machinery (`analyze_validator_output`, the same
109
+ `scripts/validate_board_yaml.py` argv, imported from `validate_cmd` rather
110
+ than re-checked here) whenever an SDK has actually resolved: a non-clean
111
+ verdict becomes a `ParseFailure("schema-violation", ...)`, flowing through
112
+ the exact same failure path a structural mismatch already does. Since
113
+ tan-cli#1484 the engine is the one `tan validate` selects (in-process by
114
+ default, the spawn only under `TAN_VALIDATE_ENGINE=subprocess`). `diff` still
115
+ never requires an SDK on its own -- with none resolved (or a stub checkout
116
+ missing `validate_board_yaml.py` -- some of this module's own tests hand it
117
+ exactly that) it falls back to the structural checks alone, exactly as
118
+ before. This is a deliberate divergence from the oracle, not a port gap: the
119
+ oracle's own `diff.exe` has the identical contradiction (measured against
120
+ `target/debug/tan.exe`) -- `diff.rs` has never called into the SDK's
121
+ validator either, so this fix does not chase parity, it changes behaviour.
122
+
123
+ **Review round (tan-cli#455 follow-up).** The first cut of the cross-check
124
+ above swallowed every reason the validator subprocess could fail to produce a
125
+ verdict -- a timeout, an unrunnable interpreter -- as a silent no-op, which
126
+ falls back to reporting a clean diff exactly as if no cross-check had ever
127
+ run: the #455 bug, reopened for a narrower trigger. It also flattened every
128
+ non-clean OUTCOME (`missing-preset`, `hardware-revision`, a crashed
129
+ validator's `failed`) into `diff.schema-violation`, the same conflation
130
+ `validate_cmd.analyze_validator_output` exists to prevent for `validate`
131
+ itself. `_reject_if_sdk_validator_disagrees` now mirrors `validate_cmd`'s own
132
+ split in full: guard 3 (`resolve_manifest_python_floor` + `_python_too_old`,
133
+ refusing BEFORE the spawn that would otherwise crash), a `TimeoutExpired`
134
+ that reclassifies to `failed` rather than a silent skip, an unstartable
135
+ subprocess that refuses as `spawn-failed` (`RUNTIME_FAILURE`) rather than a
136
+ silent skip, and `ParseFailure(result.outcome, ...)` instead of a hardcoded
137
+ `"schema-violation"` -- so `diff.failed`/`diff.missing-preset`/
138
+ `diff.hardware-revision`/`diff.hardware-revision-unknown`/
139
+ `diff.hardware-revision-not-buildable`/`diff.spawn-failed`/
140
+ `diff.python-too-old` are now real, registered outcomes alongside
141
+ `diff.schema-violation`. The last two `hardware-revision-*` arrived with
142
+ tan-cli#1262, which added validator exits 4 and 5 to the shared
143
+ `_STATUS_OUTCOME` and so widened THIS command's wire as a side effect; both
144
+ registry entries name this file as `emittedBy`, so this list is where a
145
+ reader looking one of them up lands. The ONLY
146
+ remaining silent no-op is the ORIGINAL one this section already documented:
147
+ no `validate_board_yaml.py` at the resolved checkout at all -- there is
148
+ nothing there to reuse, which is different in kind from a reuse attempt that
149
+ started and failed.
150
+ """
151
+
152
+ from __future__ import annotations
153
+
154
+ import json
155
+ import os
156
+ import re
157
+ import subprocess
158
+ from dataclasses import dataclass
159
+ from pathlib import Path
160
+ from typing import Any
161
+
162
+ import typer
163
+
164
+ from tan.commands.doctor_cmd import resolve_manifest_python_floor
165
+ from tan.commands.generate_cmd import _python_too_old
166
+ from tan.commands.presets_cmd import resolve_project_paths, resolve_sdk
167
+ from tan.commands.validate_cmd import (
168
+ OUTCOME_CLEAN,
169
+ OUTCOME_FAILED,
170
+ VALIDATOR_SCRIPT,
171
+ VALIDATOR_TIMEOUT_S,
172
+ _Finding,
173
+ run_in_process_engine,
174
+ skew_warning,
175
+ subprocess_engine_requested,
176
+ _synthesised_finding,
177
+ analyze_validator_output,
178
+ )
179
+ from tan.core.sdk_discovery import _planner_python_resolution, sdk_resolution_issues
180
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
181
+ from tan.exit_codes import ExitCode
182
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
183
+ from tan.core.shapes import yaml_kind
184
+ from tan.core.subprocess_env import spawn_env
185
+
186
+ #: `data.schemaVersion` for this command's payload -- the envelope payload's
187
+ #: own version, unrelated to `board.yaml`'s `schemaVersion:`.
188
+ DATA_SCHEMA_VERSION = "1"
189
+
190
+ #: Serialized `Iot`/`Inference` field order (`crates/tan-core/src/model.rs`'s
191
+ #: struct declaration order) -- `preserve_order` serde_json keeps this order
192
+ #: for the wire `before` value, and it is fixed regardless of the YAML
193
+ #: source's own key order, so it is spelled out here rather than derived from
194
+ #: dict iteration.
195
+ _IOT_FIELDS = ("wifi", "mqtt", "ble", "tls")
196
+ _INFERENCE_FIELDS = ("backend", "default_arena_kib")
197
+
198
+
199
+ class ParseFailure(Exception):
200
+ """A `board.yaml` this offline diff cannot process. `code` is the
201
+ `diff.<code>` issue suffix; `message` already carries the oracle's
202
+ `board.yaml is not valid[ YAML]: ...` prefix so callers pass it straight
203
+ through."""
204
+
205
+ def __init__(self, code: str, message: str, exit_code: ExitCode = ExitCode.VALIDATION_FAILURE):
206
+ self.code = code
207
+ self.message = message
208
+ self.exit_code = exit_code
209
+ super().__init__(message)
210
+
211
+
212
+ @dataclass(frozen=True)
213
+ class DiffEntry:
214
+ path: str
215
+ kind: str # "added" | "removed" | "changed" -- see module docstring
216
+ before: Any = None
217
+ after: Any = None
218
+
219
+ def as_dict(self) -> dict[str, Any]:
220
+ out: dict[str, Any] = {"path": self.path, "kind": self.kind}
221
+ if self.before is not None:
222
+ out["before"] = self.before
223
+ if self.after is not None:
224
+ out["after"] = self.after
225
+ return out
226
+
227
+
228
+ #: YAML 1.2 core schema bool literals -- `true`/`True`/`TRUE`/`false`/`False`/
229
+ #: `FALSE` only. Everything YAML 1.1 additionally resolved to bool (`on`/
230
+ #: `off`/`yes`/`no`/`y`/`n`, any case) is deliberately absent: those are the
231
+ #: exact patterns `_yaml_1_2_bool_loader` strips from PyYAML's own resolver,
232
+ #: so `re.compile` never sees them either -- one list, not two that could
233
+ #: drift apart.
234
+ _YAML_1_2_BOOL_PATTERN = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$")
235
+
236
+
237
+ def _yaml_1_2_bool_loader(yaml_module: Any) -> type:
238
+ """A `yaml_module.SafeLoader` subclass with the YAML 1.1-only loose bool
239
+ literals removed from implicit resolution, so a scalar like `on`/`off`/
240
+ `yes`/`no`/`y`/`n` (any case) parses as a plain STRING -- matching
241
+ `serde_yaml`'s YAML 1.2 core-schema bool tag (see the module docstring's
242
+ "YAML 1.1 vs 1.2 boolean literals" note). Takes the imported `yaml`
243
+ module rather than importing it itself, so a build with no PyYAML
244
+ installed never touches `yaml.SafeLoader` at all -- `_load_document`
245
+ only calls this after its own optional import already succeeded.
246
+ """
247
+
248
+ class Yaml12BoolLoader(yaml_module.SafeLoader):
249
+ pass
250
+
251
+ # `add_implicit_resolver` only APPENDS; the stock YAML-1.1 bool resolver
252
+ # would still match first and win. Copy the resolver table with every
253
+ # existing bool entry stripped, then append the narrower one, so this
254
+ # loader's `tag:yaml.org,2002:bool` entries are exactly the six literals
255
+ # above -- nothing from the base `SafeLoader` (used unmodified everywhere
256
+ # else in tan) is touched.
257
+ Yaml12BoolLoader.yaml_implicit_resolvers = {
258
+ first: [pair for pair in resolvers if pair[0] != "tag:yaml.org,2002:bool"]
259
+ for first, resolvers in yaml_module.SafeLoader.yaml_implicit_resolvers.items()
260
+ }
261
+ Yaml12BoolLoader.add_implicit_resolver(
262
+ "tag:yaml.org,2002:bool", _YAML_1_2_BOOL_PATTERN, list("tTfF")
263
+ )
264
+ return Yaml12BoolLoader
265
+
266
+
267
+ def _load_document(text: str) -> Any:
268
+ """The raw YAML document, or a `ParseFailure` matching `ParseError`'s two
269
+ reachable variants on this path (`Yaml`, and the `som:`-shape pre-check).
270
+ `EmptyDocument`/`NotAMapping` are NOT reachable here -- those are
271
+ `validate_cmd`'s `reject_non_mapping_document`, which `diff` never calls;
272
+ a null or bare-scalar document degrades to the default (empty) model here,
273
+ matching `parse_board_model`'s own TS-parity leniency.
274
+ """
275
+ try:
276
+ import yaml # noqa: PLC0415 (optional at runtime, by design)
277
+ except ImportError as err:
278
+ raise ParseFailure(
279
+ "pyyaml-unavailable",
280
+ "this build of tan has no YAML support installed, so `tan diff` cannot "
281
+ "compute a normalization diff.",
282
+ ExitCode.RUNTIME_FAILURE,
283
+ ) from err
284
+ try:
285
+ return yaml.load(text, Loader=_yaml_1_2_bool_loader(yaml))
286
+ except Exception as err: # noqa: BLE001 -- yaml.YAMLError and anything a loader raises
287
+ raise ParseFailure("schema-violation", f"board.yaml is not valid YAML: {err}") from err
288
+
289
+
290
+ #: tan-cli#408: one implementation, in `tan.core.shapes` -- `pinmux_cmd.py`
291
+ #: carried a byte-identical copy. The module docstring's scope note (this is
292
+ #: not a claim of matching serde's exact wording) moved with it.
293
+ _yaml_kind = yaml_kind
294
+
295
+
296
+ def _typed_field(doc: dict, key: str, expected: type, label: str) -> Any:
297
+ """`doc[key]` if absent or already `expected`-shaped, else a
298
+ `ParseFailure` mirroring the whole-document failure a struct-typed
299
+ `serde_yaml` deserialize would raise for the same mismatch."""
300
+ value = doc.get(key)
301
+ if value is None or isinstance(value, expected):
302
+ return value
303
+ raise ParseFailure(
304
+ "schema-violation",
305
+ f"board.yaml is not valid YAML: {key}: expected {label}, got {_yaml_kind(value)}",
306
+ )
307
+
308
+
309
+ def _string_scalar_field(doc: dict, key: str) -> str | None:
310
+ """`doc[key]` coerced the way a `serde_yaml` `String`-typed field actually
311
+ deserializes: ANY scalar coerces into a string (only the compound shapes
312
+ `list`/`dict` a `String` field can never hold are refused) -- unlike
313
+ `_typed_field(..., str, ...)`, which wrongly demanded the YAML node
314
+ already BE a Python `str` (tan-cli#570). Matches
315
+ `_check_inference_field_types`'s existing treatment of
316
+ `inference.backend`, which this module's own docstring already claimed
317
+ `os`/`preset` shared before the code actually did.
318
+
319
+ Measured against the oracle (tan-cli#570): `os: true` -> `"true"`,
320
+ `os: 5` -> `"5"`. `bool` is special-cased to lowercase text rather than
321
+ falling through to `str(value)` (which would yield Python's `"True"`) --
322
+ every canonical YAML 1.2 bool spelling this loader can produce
323
+ (`_Yaml12BoolLoader`) already normalizes to Python's `True`/`False`, so
324
+ there is no source-text case to preserve here the way there would be for
325
+ a number or a YAML timestamp.
326
+ """
327
+ value = doc.get(key)
328
+ if value is None:
329
+ return None
330
+ if isinstance(value, (list, dict)):
331
+ raise ParseFailure(
332
+ "schema-violation",
333
+ f"board.yaml is not valid YAML: {key}: expected a string, got {_yaml_kind(value)}",
334
+ )
335
+ if isinstance(value, str):
336
+ return value
337
+ if isinstance(value, bool):
338
+ return "true" if value else "false"
339
+ return str(value)
340
+
341
+
342
+ #: `u32::MAX` -- the upper bound `inference.default_arena_kib` (`u32` in the
343
+ #: Rust model) accepts. Measured against the oracle: `4294967295` is exit 0,
344
+ #: `4294967296` is exit 2 `inference.default_arena_kib: ... expected u32`.
345
+ _U32_MAX = 0xFFFFFFFF
346
+
347
+
348
+ def _typed_nested(mapping: dict, key: str, path: str, expected: type, label: str) -> Any:
349
+ """Like `_typed_field`, but for a key nested one level under an
350
+ already-`dict`-shaped `mapping` -- `path` is the dotted diagnostic path
351
+ (`"iot.wifi"`) rather than a bare top-level key."""
352
+ value = mapping.get(key)
353
+ if value is None or isinstance(value, expected):
354
+ return value
355
+ raise ParseFailure(
356
+ "schema-violation",
357
+ f"board.yaml is not valid YAML: {path}: expected {label}, got {_yaml_kind(value)}",
358
+ )
359
+
360
+
361
+ def _check_iot_field_types(iot: dict | None) -> None:
362
+ """Each of `iot`'s four toggles must be `bool` or absent -- checked
363
+ before `compute_diff_entries` ever asks whether the group is prunable
364
+ (see the module docstring's "scope of the structural checks" note)."""
365
+ if iot is None:
366
+ return
367
+ for field in _IOT_FIELDS:
368
+ _typed_nested(iot, field, f"iot.{field}", bool, "a boolean")
369
+
370
+
371
+ def _check_inference_field_types(inference: dict | None) -> None:
372
+ """`inference.default_arena_kib` must be a non-negative `u32`-range
373
+ integer or absent. `inference.backend` is a `String` field: matching the
374
+ `os`/`preset` leniency documented at the top of the module, only the
375
+ compound shapes (`list`/`dict`) no `String` field can ever hold are
376
+ rejected here -- every other scalar PyYAML resolves it to is accepted,
377
+ same as the oracle's own coercion (`_inference_is_empty` is what needed
378
+ fixing to stop mis-treating a non-`str` truthy `backend` as blank)."""
379
+ if inference is None:
380
+ return
381
+ backend = inference.get("backend")
382
+ if isinstance(backend, (list, dict)):
383
+ raise ParseFailure(
384
+ "schema-violation",
385
+ f"board.yaml is not valid YAML: inference.backend: expected a string, "
386
+ f"got {_yaml_kind(backend)}",
387
+ )
388
+ arena = inference.get("default_arena_kib")
389
+ if arena is not None and (
390
+ isinstance(arena, bool) or not isinstance(arena, int) or not 0 <= arena <= _U32_MAX
391
+ ):
392
+ raise ParseFailure(
393
+ "schema-violation",
394
+ "board.yaml is not valid YAML: inference.default_arena_kib: expected a "
395
+ f"non-negative 32-bit integer, got {_yaml_kind(arena)}",
396
+ )
397
+
398
+
399
+ def _parse_fields(doc: Any) -> tuple[int, str | None, list | None, dict | None, dict | None]:
400
+ """`(effective_schema_version, os, libraries, iot, inference)` -- the only
401
+ values `normalize_board_model` can ever act on. Raises `ParseFailure` for
402
+ every document shape that would fail the Rust's typed parse; see the
403
+ module docstring for exactly how far the type checking goes.
404
+ """
405
+ if doc is None:
406
+ doc = {}
407
+ if not isinstance(doc, dict):
408
+ raise ParseFailure(
409
+ "schema-violation",
410
+ f"board.yaml is not valid YAML: invalid type: {_yaml_kind(doc)}, expected a mapping",
411
+ )
412
+
413
+ som = doc.get("som")
414
+ if som is not None and not isinstance(som, dict):
415
+ raise ParseFailure(
416
+ "schema-violation",
417
+ "board.yaml is not valid: `som:` must be a mapping carrying a `sku:` key, but "
418
+ f"a scalar was given ({som!r}). Write it as:\n som:\n sku: <SKU>",
419
+ )
420
+
421
+ schema_version = doc.get("schemaVersion")
422
+ schema_version_ok = isinstance(schema_version, int) and not isinstance(schema_version, bool)
423
+ # `schemaVersion` is `u32` in the Rust model (`model.rs:18`), so the upper
424
+ # bound matters just as much as the lower one -- tan-cli#571: without the
425
+ # `_U32_MAX` ceiling (already applied to `inference.default_arena_kib`
426
+ # below), a value above it silently fell through and got treated as
427
+ # `>= 2`, the opposite of the oracle's exit-2 refusal.
428
+ if schema_version is not None and (
429
+ not schema_version_ok or schema_version < 0 or schema_version > _U32_MAX
430
+ ):
431
+ raise ParseFailure(
432
+ "schema-violation",
433
+ f"board.yaml is not valid YAML: schemaVersion: expected a non-negative 32-bit "
434
+ f"integer, got {_yaml_kind(schema_version)}",
435
+ )
436
+ effective_version = schema_version if schema_version is not None else 1
437
+
438
+ os_value = _string_scalar_field(doc, "os")
439
+ libraries = _typed_field(doc, "libraries", list, "a sequence")
440
+ iot = _typed_field(doc, "iot", dict, "a mapping")
441
+ inference = _typed_field(doc, "inference", dict, "a mapping")
442
+ _check_iot_field_types(iot)
443
+ _check_inference_field_types(inference)
444
+
445
+ # Fields `diff` never reads (never touched by normalize_board_model, so
446
+ # never contribute a diff entry either way) -- top-level shape checked
447
+ # only, per the module docstring's scope note.
448
+ _string_scalar_field(doc, "preset")
449
+ _typed_field(doc, "cores", dict, "a mapping")
450
+ _typed_field(doc, "ipc", list, "a sequence")
451
+ _typed_field(doc, "diagnostics", dict, "a mapping")
452
+ _typed_field(doc, "populated", dict, "a mapping")
453
+ _typed_field(doc, "chips", list, "a sequence")
454
+ _typed_field(doc, "e1m_routes", dict, "a mapping")
455
+
456
+ return effective_version, os_value, libraries, iot, inference
457
+
458
+
459
+ def _iot_any_enabled(iot: dict) -> bool:
460
+ return any(iot.get(k) is True for k in _IOT_FIELDS)
461
+
462
+
463
+ def _iot_pruned(iot: dict) -> dict:
464
+ return {k: iot[k] for k in _IOT_FIELDS if iot.get(k) is not None}
465
+
466
+
467
+ def _inference_is_empty(inference: dict) -> bool:
468
+ """`backend` is empty only when absent or an explicit empty string --
469
+ any OTHER present scalar (`_check_inference_field_types` has already
470
+ rejected the compound shapes) counts as non-empty regardless of its YAML
471
+ type, matching the oracle's own `String`-field coercion (measured:
472
+ `inference: {backend: 5}` is `unchanged: true`, never pruned). Naively
473
+ defaulting a non-`str` `backend` to `""` here -- as this used to -- is
474
+ exactly the bug: it silently treated a present, non-empty `backend` as
475
+ blank and let `compute_diff_entries` fabricate a diff entry the oracle
476
+ never emits."""
477
+ backend = inference.get("backend")
478
+ if backend is not None and backend != "":
479
+ return False
480
+ return inference.get("default_arena_kib") is None
481
+
482
+
483
+ def _inference_pruned(inference: dict) -> dict:
484
+ return {k: inference[k] for k in _INFERENCE_FIELDS if inference.get(k) is not None}
485
+
486
+
487
+ def compute_diff_entries(
488
+ effective_version: int,
489
+ os_value: str | None,
490
+ libraries: list | None,
491
+ iot: dict | None,
492
+ inference: dict | None,
493
+ ) -> list[DiffEntry]:
494
+ """The `normalize_board_model` effect as `DiffEntry` list, sorted by path
495
+ (matching `collect_diff_entries`'s own `sort_by(path)` -- alphabetical
496
+ among `inference`/`iot`/`libraries`/`os` needs no explicit sort since at
497
+ most one of `{inference, iot, libraries}` XOR `{os}` group is ever
498
+ populated, but sorting keeps the guarantee explicit rather than accidental).
499
+ """
500
+ entries: list[DiffEntry] = []
501
+ if effective_version < 2:
502
+ if libraries is not None and len(libraries) == 0:
503
+ entries.append(DiffEntry("libraries", "removed", before=[]))
504
+ if iot is not None and not _iot_any_enabled(iot):
505
+ entries.append(DiffEntry("iot", "removed", before=_iot_pruned(iot)))
506
+ if inference is not None and _inference_is_empty(inference):
507
+ entries.append(DiffEntry("inference", "removed", before=_inference_pruned(inference)))
508
+ else:
509
+ if os_value is not None:
510
+ entries.append(DiffEntry("os", "removed", before=os_value))
511
+ entries.sort(key=lambda e: e.path)
512
+ return entries
513
+
514
+
515
+ _KIND_LABEL = {"added": "ADDED", "removed": "REMOVED", "changed": "CHANGED"}
516
+
517
+
518
+ def _format_value(value: Any) -> str:
519
+ """`<undefined>` for `None`, JSON-quoted for a string, compact JSON
520
+ otherwise, truncated to 117 chars + `...` past 120 -- verbatim from
521
+ `diff.rs`'s `format_value`. `ensure_ascii=False`: `serde_json::to_string`
522
+ emits raw UTF-8, never a `\\uXXXX` escape, for a non-ASCII board.yaml
523
+ string."""
524
+ if value is None:
525
+ return "<undefined>"
526
+ if isinstance(value, str):
527
+ return json.dumps(value, ensure_ascii=False)
528
+ raw = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
529
+ if len(raw) > 120:
530
+ return raw[:117] + "..."
531
+ return raw
532
+
533
+
534
+ def _render_text(entries: list[DiffEntry], board_path: str, quiet: bool) -> list[str]:
535
+ if not entries:
536
+ return ["diff: no effective-config differences detected."]
537
+ lines = [f"diff: {len(entries)} differences in {board_path}"]
538
+ if not quiet:
539
+ for entry in entries:
540
+ lines.append(
541
+ f"{_KIND_LABEL[entry.kind]} {entry.path}: "
542
+ f"{_format_value(entry.before)} -> {_format_value(entry.after)}"
543
+ )
544
+ return lines
545
+
546
+
547
+ def _data(
548
+ board_path: str, entries: list[DiffEntry], *, unchanged: bool | None = None
549
+ ) -> dict[str, Any]:
550
+ """`unchanged` defaults to `len(entries) == 0` for the success path, but a
551
+ FAILURE envelope's `DiffData` hardcodes `unchanged: false` regardless of
552
+ its (always-empty) `changes` list -- verbatim from `diff.rs`'s `failure()`
553
+ -- so `_emit_failure` passes it explicitly rather than letting an empty
554
+ `changes: []` compute `unchanged: true` for a run that never got far
555
+ enough to answer that question.
556
+
557
+ **No `validatorExitStatus` here, knowingly (tan-cli#1262).** That change
558
+ gave `validate` a `data.validatorExitStatus` carrying the spawned
559
+ validator's raw returncode, so a consumer keeps the distinction when an
560
+ exit falls through `_STATUS_OUTCOME.get(..., OUTCOME_FAILED)`. `diff`
561
+ reuses the same `_spawn_validator`/`_STATUS_OUTCOME` and so inherited the
562
+ two new `hardware-revision-*` outcome codes -- but NOT the raw status,
563
+ because it never reaches this function on that path: the outcome travels
564
+ as a `ParseFailure.code` through `_emit_failure`, which builds its `data`
565
+ from `_data(...)` alone and has no channel for a number. So a `tan diff`
566
+ against an unmapped validator exit is still lossy, arriving as
567
+ `diff.failed` with nothing to re-derive it from. Threading the status
568
+ through `_spawn_validator` -> `ParseFailure` -> `_emit_failure` is a
569
+ second wire change on a second command and is tracked separately; it was
570
+ deliberately NOT folded into #1262."""
571
+ return {
572
+ "schemaVersion": DATA_SCHEMA_VERSION,
573
+ "boardYamlPath": board_path,
574
+ "unchanged": (len(entries) == 0) if unchanged is None else unchanged,
575
+ "changeCount": len(entries),
576
+ "changes": [e.as_dict() for e in entries],
577
+ }
578
+
579
+
580
+ def _emit_failure(
581
+ *,
582
+ json_mode: bool,
583
+ root: str,
584
+ board_path: str,
585
+ code: str,
586
+ message: str,
587
+ exit_code: ExitCode,
588
+ text_lines: list[str],
589
+ sdk: SdkInfo | None = None,
590
+ sdk_context_issues: list[Issue] | None = None,
591
+ ) -> None:
592
+ """Mirrors `diff.rs`'s `failure(...)`: the JSON issue message and the
593
+ text-mode lines are independent strings, not one derived from the other
594
+ (`board-yaml-missing`'s text line reads differently from its issue
595
+ message) -- callers supply `text_lines` verbatim, matching the Rust
596
+ call sites' own hand-written `vec![...]`. Unlike the success path's
597
+ `_render_text`, these lines are NOT filtered by `--quiet` -- measured
598
+ against the oracle: `diff --quiet` on every failure prints the identical
599
+ lines a plain `diff` does.
600
+
601
+ `sdk` is the resolved `--sdk-root` block, carried through to the failure
602
+ envelope exactly as the success envelope carries it -- measured against
603
+ the oracle: `diff --sdk-root <path>` against a missing board.yaml still
604
+ reports `sdk.root`/`sdk.sourceTier` on the exit-2 envelope; dropping it
605
+ on the failure path (as this used to) is a real divergence, not just an
606
+ asymmetry with the success path.
607
+ """
608
+ if json_mode:
609
+ emit(
610
+ Envelope(
611
+ "diff",
612
+ Project.resolved(root, board_path),
613
+ _data(board_path, [], unchanged=False),
614
+ [*(sdk_context_issues or []), Issue(f"diff.{code}", "error", message)],
615
+ exit_code,
616
+ sdk=sdk,
617
+ )
618
+ )
619
+ else:
620
+ stream = typer.get_text_stream("stderr")
621
+ # tan-cli#478 review finding 6: the JSON envelope has carried the
622
+ # foreign-default pair since the `Envelope` seam landed, but the
623
+ # DEFAULT text path never read `sdk_context_issues` at all -- a
624
+ # customer running plain `tan diff` (no `--format json`) saw only
625
+ # `text_lines` and never learned another project's checkout decided
626
+ # this. Printed first, matching the JSON list's own ordering.
627
+ for issue in sdk_context_issues or []:
628
+ stream.write(f"{issue.message}\n")
629
+ for line in text_lines:
630
+ stream.write(f"{line}\n")
631
+ raise typer.Exit(int(exit_code))
632
+
633
+
634
+ def _spawn_validator(
635
+ python_binary: str, script: str, board_path: str, *, used_workspace_venv: bool = True
636
+ ) -> tuple[str, tuple[Any, ...]]:
637
+ """`(outcome, findings)` for one run of the SDK's own validator, or a
638
+ `ParseFailure("spawn-failed", ...)` when the subprocess could not even be
639
+ started -- `validate_cmd`'s own split (tan-cli#262/#455 review round),
640
+ reused rather than re-derived: a launch that never happened is not a
641
+ verdict, and must not be swallowed into a silent "nothing to report".
642
+
643
+ `used_workspace_venv` (tan-cli#652) is forwarded verbatim to
644
+ `_synthesised_finding`: `False` means `python_binary` is a bare PATH
645
+ fallback (no `tan bootstrap` workspace venv resolved for this project),
646
+ which is what lets a missing-module crash there name `tan bootstrap` as
647
+ the remedy instead of surfacing a raw `ModuleNotFoundError`."""
648
+ command_line = f"{python_binary} {script} --input {board_path}"
649
+ try:
650
+ out = subprocess.run(
651
+ [python_binary, script, "--input", board_path],
652
+ capture_output=True,
653
+ text=True,
654
+ encoding="utf-8",
655
+ errors="replace",
656
+ stdin=subprocess.DEVNULL,
657
+ timeout=VALIDATOR_TIMEOUT_S,
658
+ env=spawn_env(),
659
+ check=False,
660
+ )
661
+ except subprocess.TimeoutExpired:
662
+ # The child STARTED, so this is a verdict that never arrived, not a
663
+ # launch failure -- mirrors validate_cmd's own tan-cli#262 shape.
664
+ return OUTCOME_FAILED, (
665
+ _Finding(
666
+ "error",
667
+ f"the SDK validator did not finish within {VALIDATOR_TIMEOUT_S}s "
668
+ f"and was killed: {command_line}",
669
+ ),
670
+ )
671
+ except (OSError, ValueError, subprocess.SubprocessError) as err:
672
+ raise ParseFailure(
673
+ "spawn-failed",
674
+ f"could not run the SDK validator ({command_line}): {err}",
675
+ ExitCode.RUNTIME_FAILURE,
676
+ ) from err
677
+ result = analyze_validator_output(out.returncode, out.stderr)
678
+ if result.outcome != OUTCOME_CLEAN and not result.findings:
679
+ # A non-clean run must never reach a consumer as "zero issues",
680
+ # which reads as no problem -- `to_cli_issues`' own synthesis.
681
+ return result.outcome, (
682
+ _synthesised_finding(
683
+ result.outcome, out.stderr, used_workspace_venv=used_workspace_venv
684
+ ),
685
+ )
686
+ return result.outcome, result.findings
687
+
688
+
689
+ def _reject_if_sdk_validator_disagrees(sdk_info: SdkInfo, root: str, board_path: str) -> None:
690
+ """Raise `ParseFailure(<outcome>, ...)` when the validator engine `tan
691
+ validate` uses (the in-process port by default; the SDK's own
692
+ `scripts/validate_board_yaml.py` under `TAN_VALIDATE_ENGINE=subprocess`)
693
+ finds this board.yaml invalid, or its own environment cannot
694
+ even answer that question (tan-cli#455; see the module docstring's "SDK
695
+ cross-check" / "Review round" sections for the false-clean bug this
696
+ closes and why the outcome, not a hardcoded `"schema-violation"`, is what
697
+ reaches the wire).
698
+
699
+ A no-op ONLY when there is nothing to reuse at all: no
700
+ `validate_board_yaml.py` at the resolved checkout (a stub/incomplete SDK
701
+ -- this module's own `--sdk-root` tests hand it exactly that, and must
702
+ keep passing unmodified). Every attempt that STARTS and then fails to
703
+ reach a verdict -- guard 3's interpreter floor, an unstartable
704
+ subprocess, a timeout -- refuses instead.
705
+ """
706
+ script = os.path.join(sdk_info.root, *VALIDATOR_SCRIPT)
707
+ if not subprocess_engine_requested():
708
+ # Same engine `tan validate` defaults to (tan tan-cli#1484): the
709
+ # in-process port, so the two commands cannot disagree. A checkout with
710
+ # no `metadata/` (a stub) has nothing to validate against -- a no-op.
711
+ if not os.path.isdir(os.path.join(sdk_info.root, "metadata")):
712
+ return
713
+ _status, _stderr, result = run_in_process_engine(board_path, Path(sdk_info.root))
714
+ if result.outcome == OUTCOME_CLEAN:
715
+ return
716
+ detail = "; ".join(finding.message for finding in result.findings)
717
+ if result.crashed:
718
+ # The port crashed: board.yaml was never judged (tan-cli#1523).
719
+ raise ParseFailure(
720
+ result.outcome,
721
+ f"could not check board.yaml before diffing: {detail}",
722
+ )
723
+ skew = skew_warning(Path(sdk_info.root))
724
+ raise ParseFailure(
725
+ result.outcome,
726
+ "board.yaml is not valid: tan's validator rejects it -- run "
727
+ f"`tan validate` for full diagnostics: {detail}"
728
+ + (f" (caveat: {skew})" if skew else ""),
729
+ )
730
+ if not os.path.isfile(script):
731
+ return
732
+ # tan-cli#652: `used_workspace_venv` is threaded through to
733
+ # `_spawn_validator`/`_synthesised_finding` so a missing-module crash on
734
+ # the bare-PATH fallback names `tan bootstrap` as the remedy.
735
+ python_binary, used_workspace_venv = _planner_python_resolution(
736
+ os.path.abspath(root), sdk_info.root
737
+ )
738
+
739
+ # Guard 3 (validate_cmd.py:1013-1015's own): a spawned interpreter below
740
+ # the SDK's declared pythonMinVersion dies inside alp-sdk's
741
+ # `@dataclass(slots=True)` scripts with a traceback -- refuse with the
742
+ # actual defect before that traceback ever happens, rather than let
743
+ # `_spawn_validator` reclassify it to a generic `failed`.
744
+ floor, _floor_source = resolve_manifest_python_floor(sdk_info.root)
745
+ if (too_old := _python_too_old(python_binary, floor)) is not None:
746
+ raise ParseFailure("python-too-old", too_old)
747
+
748
+ outcome, findings = _spawn_validator(
749
+ python_binary, script, board_path, used_workspace_venv=used_workspace_venv
750
+ )
751
+ if outcome == OUTCOME_CLEAN:
752
+ return
753
+ # `finding.message`, not a `(severity, message)` unpack: tan-cli#498
754
+ # defect 2 turned `validate_cmd`'s findings into a `_Finding` record so a
755
+ # rich `error[ALP-Bxxx]` block's code, hint and source range survive the
756
+ # walk. This module reuses that parser wholesale, so it follows the shape.
757
+ # The bare `.message` is deliberate here -- `diff` folds every finding into
758
+ # ONE sentence and points the reader at `tan validate` for the full
759
+ # diagnostics, which is where the code and hint are rendered.
760
+ detail = "; ".join(finding.message for finding in findings)
761
+ raise ParseFailure(
762
+ outcome,
763
+ "board.yaml is not valid: the SDK's own validator rejects it -- run "
764
+ f"`tan validate` for full diagnostics: {detail}",
765
+ )
766
+
767
+
768
+ def diff(
769
+ ctx: typer.Context,
770
+ project: str = typer.Option(
771
+ None, "--project", metavar="PATH", help="Project root (defaults to current directory)."
772
+ ),
773
+ board_yaml: str = typer.Option(
774
+ None,
775
+ "--board-yaml",
776
+ metavar="PATH",
777
+ help="Explicit board.yaml path (overrides project resolution).",
778
+ ),
779
+ sdk_root: str = typer.Option(
780
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
781
+ ),
782
+ target: str = typer.Option( # accepted, not read
783
+ None,
784
+ "--target",
785
+ metavar="EMIT",
786
+ help="Generation target (e.g. zephyr-conf, dts-overlay, cmake-args, yocto-conf).",
787
+ ),
788
+ all_targets: bool = typer.Option( # accepted, not read
789
+ False, "--all", help="Run command against all relevant targets."
790
+ ),
791
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
792
+ verbose: bool = typer.Option( # accepted, not read
793
+ False, "--verbose", help="Emit additional diagnostic detail."
794
+ ),
795
+ quiet: bool = typer.Option(
796
+ False, "--quiet", help="Suppress non-essential output (omits the per-change lines)."
797
+ ),
798
+ no_color: bool = typer.Option( # accepted, not read; diff emits no ANSI color
799
+ False, "--no-color", help="Disable ANSI color in text output."
800
+ ),
801
+ non_interactive: bool = typer.Option( # accepted, not read; diff never prompts
802
+ False, "--non-interactive", help="Never prompt."
803
+ ),
804
+ ci: bool = typer.Option( # accepted, not read
805
+ False, "--ci", help="CI mode: implies non-interactive and disables color."
806
+ ),
807
+ ) -> None:
808
+ """Show how board.yaml normalization changes the effective config.
809
+
810
+ `--target`/`--all`/`--verbose`/`--no-color`/`--non-interactive`/`--ci` are
811
+ declared, not consumed: `diff` reads only the project's own board.yaml
812
+ plus, now, `--sdk-root` -- solely to echo the resolved SDK in the
813
+ envelope's `sdk` block, matching the oracle (measured: `diff --sdk-root
814
+ <path>` reports `sdk.root`/`sdk.sourceTier` on both the success AND the
815
+ board-yaml-missing failure envelope; `diff` still never READS anything
816
+ from the checkout). The oracle's clap `GlobalArgs` are `global = true`,
817
+ so every verb accepts all of them and a caller passing one through
818
+ unconditionally must not get a parse error.
819
+ """
820
+ del target, all_targets, verbose, no_color, non_interactive, ci
821
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
822
+ json_mode = resolved_format == "json"
823
+
824
+ root, board_path = resolve_project_paths(project, board_yaml)
825
+ sdk = resolve_sdk(sdk_root, root)
826
+ # tan-cli#468: `resolve_sdk` now always returns an `ActiveSdk`, never a
827
+ # bare `None` -- `sdk.path` is what says whether a usable checkout
828
+ # resolved, so `sdk_info` guards on that instead of `sdk`'s own
829
+ # (now-unconditional) truthiness.
830
+ sdk_info = SdkInfo.from_resolution(sdk.path, sdk) if sdk.path is not None else None
831
+ # tan-cli#478: `SdkInfo.from_resolution` above carries the pair, and
832
+ # `Envelope.__init__` appends it to the JSON envelope for every command --
833
+ # that seam alone is enough for `--format json`, and dedupes by code, so
834
+ # computing it again here changes nothing there. But `diff`'s text mode
835
+ # never touches `Envelope` at all (`_emit_failure`'s `else` branch and the
836
+ # success branch below both write straight to stderr), so without this
837
+ # the DEFAULT (non-JSON) path stayed silent -- tan-cli#478 review finding
838
+ # 6. Computed here, once, and threaded into both text branches below.
839
+ #
840
+ # Unconditional now (tan-cli#468): `sdk.broken_project_pin`/
841
+ # `sdk.foreign_global_default_for` are populated even when `sdk.path` is
842
+ # `None`, so a broken pin with nothing else resolving reaches `diff`'s
843
+ # text mode too, not only the resolved-checkout case.
844
+ sdk_context_issues: list[Issue] = sdk_resolution_issues(
845
+ sdk.broken_project_pin, sdk.tier, sdk.foreign_global_default_for
846
+ )
847
+ board_file = Path(board_path)
848
+
849
+ if not board_file.exists():
850
+ _emit_failure(
851
+ json_mode=json_mode,
852
+ root=root,
853
+ board_path=board_path,
854
+ code="board-yaml-missing",
855
+ message="board.yaml path could not be resolved or the file does not exist.",
856
+ exit_code=ExitCode.VALIDATION_FAILURE,
857
+ text_lines=["diff: board.yaml path is unresolved or missing."],
858
+ sdk=sdk_info,
859
+ sdk_context_issues=sdk_context_issues,
860
+ )
861
+ return
862
+
863
+ try:
864
+ text = board_file.read_text(encoding="utf-8")
865
+ except (OSError, UnicodeDecodeError) as err:
866
+ _emit_failure(
867
+ json_mode=json_mode,
868
+ root=root,
869
+ board_path=board_path,
870
+ code="internal-failure",
871
+ message=f"could not read board.yaml: {err}",
872
+ exit_code=ExitCode.INTERNAL_FAILURE,
873
+ text_lines=["diff: internal failure", str(err)],
874
+ sdk=sdk_info,
875
+ sdk_context_issues=sdk_context_issues,
876
+ )
877
+ return
878
+
879
+ try:
880
+ doc = _load_document(text)
881
+ effective_version, os_value, libraries, iot, inference = _parse_fields(doc)
882
+ if sdk_info is not None:
883
+ _reject_if_sdk_validator_disagrees(sdk_info, root, board_path)
884
+ entries = compute_diff_entries(effective_version, os_value, libraries, iot, inference)
885
+ except ParseFailure as failure:
886
+ header = (
887
+ "diff: internal failure"
888
+ if failure.exit_code == ExitCode.INTERNAL_FAILURE
889
+ else "diff: validation failure"
890
+ if failure.exit_code == ExitCode.VALIDATION_FAILURE
891
+ else "diff: runtime failure"
892
+ )
893
+ _emit_failure(
894
+ json_mode=json_mode,
895
+ root=root,
896
+ board_path=board_path,
897
+ code=failure.code,
898
+ message=failure.message,
899
+ exit_code=failure.exit_code,
900
+ text_lines=[header, failure.message],
901
+ sdk=sdk_info,
902
+ sdk_context_issues=sdk_context_issues,
903
+ )
904
+ return
905
+ except Exception as err: # noqa: BLE001 -- the envelope IS the error contract
906
+ message = f"diff failed unexpectedly: {err.__class__.__name__}: {err}"
907
+ _emit_failure(
908
+ json_mode=json_mode,
909
+ root=root,
910
+ board_path=board_path,
911
+ code="internal-failure",
912
+ message=message,
913
+ exit_code=ExitCode.INTERNAL_FAILURE,
914
+ text_lines=["diff: internal failure", message],
915
+ sdk=sdk_info,
916
+ sdk_context_issues=sdk_context_issues,
917
+ )
918
+ return
919
+
920
+ if json_mode:
921
+ emit(
922
+ Envelope(
923
+ "diff",
924
+ Project.resolved(root, board_path),
925
+ _data(board_path, entries),
926
+ sdk_context_issues,
927
+ ExitCode.SUCCESS,
928
+ sdk=sdk_info,
929
+ )
930
+ )
931
+ else:
932
+ stream = typer.get_text_stream("stderr")
933
+ # tan-cli#478 review finding 6: printed unconditionally, unlike the
934
+ # `--quiet`-suppressed entries below -- a foreign-checkout warning is
935
+ # not the "non-essential" output `--quiet` exists to trim.
936
+ for issue in sdk_context_issues:
937
+ stream.write(f"{issue.message}\n")
938
+ for line in _render_text(entries, board_path, quiet):
939
+ stream.write(f"{line}\n")
940
+ raise typer.Exit(int(ExitCode.SUCCESS))