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,2009 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan validate` -- is this `board.yaml` structurally sound?
3
+
4
+ Two paths, mirroring `crates/tan-cli/src/commands/validate.rs`:
5
+
6
+ * ``--offline`` runs only the structural checks that ship inside tan. No SDK
7
+ checkout, no subprocess, no network. This is the path the two committed
8
+ conformance fixtures exercise, which is why their ``data.commandLine`` is
9
+ ``""``.
10
+ * without ``--offline`` the real validator is a PORT of the SDK's own
11
+ ``scripts/validate_board_yaml.py``, **run in-process since tan-cli#270**
12
+ (``tan.core.board_validator_run``; ``tests/parity/test_board_validator_parity.py``
13
+ holds it byte-for-byte to the script over the SDK's examples and an invalid-
14
+ board corpus, and ``HAND_PORT_HASHES`` pins the four SDK sources it was cut
15
+ from). tan still does not vendor alp-sdk's schema: the SDK owns
16
+ ``metadata/schemas/`` and ADR-0017's doctrine is to consume what exists, so
17
+ the port reads ``metadata/**`` out of the resolved checkout. The port returns
18
+ what the script's process would have (exit status + stderr), and that goes
19
+ through the SAME ``analyze_validator_output`` below -- the status map, issue
20
+ codes and envelope are unchanged.
21
+
22
+ **Version skew (the one behavioural difference).** The port applies the
23
+ validator rules tan was audited against (``PORTED_FROM_SDK_COMMIT``) to
24
+ WHATEVER checkout is bound; the spawned script always matched the bound one.
25
+ A newer SDK's extra checks are not applied (tan warns with
26
+ ``validate.sdk-validator-newer`` when the bound checkout's validator sources
27
+ hash differently from the pinned ones), and a crash reading such a
28
+ checkout is reported as ``validate.failed`` saying tan's validator could not
29
+ read this SDK -- not as a board defect. ``TAN_VALIDATE_ENGINE=subprocess``
30
+ runs the bound SDK's own validator instead.
31
+
32
+ **Why the spawn path below still exists:** that env var pins it
33
+ deliberately (the parity test's reference side, the skew escape hatch),
34
+ ``tan diff`` shares its helpers, and ``validate.spawn-failed`` /
35
+ ``validate.python-too-old`` are registered codes it alone can emit -- the
36
+ registry's retired-spelling gate matches a bare suffix across ``tan/``, so
37
+ they cannot be retired while ``diff.spawn-failed`` / ``diff.python-too-old`` /
38
+ ``bootstrap.python-too-old`` share it. On the default engine no interpreter is
39
+ probed and ``data.commandLine`` is ``""``. **PORTED (tan-cli#376)** -- see
40
+ "the spawn path" below. Until #376 this branch refused with
41
+ ``validate.spawn-not-implemented`` at exit 2, which made the DEFAULT
42
+ invocation the root quickstart documents (``tan validate``) incapable of
43
+ validating anything, and -- because exit 2 is also the genuine
44
+ validation-failure class -- indistinguishable from a bad board to any
45
+ caller that does not read the issue code.
46
+
47
+ **tan-cli#262 (decided for 0.5.0, TAKEN): a missing verdict is the
48
+ VALIDATOR's problem, not a tan crash -- exit 2, not exit 1.** An earlier
49
+ revision of this docstring left that an open question ("the genuine
50
+ release decision, tracked in tan-cli#262"); the maintainer has now
51
+ decided it. Measured
52
+ directly, running ``target/debug/tan.exe`` (tan 0.4.1-dev) with
53
+ ``--format json``:
54
+
55
+ - empty directory -> exit **2**, ``validate.board-yaml-missing``
56
+ - ``board.yaml`` present, no SDK root -> exit **2**,
57
+ ``validate.sdk-root-unresolved``
58
+
59
+ Exit 1 is reachable in the oracle only AFTER a validator actually spawns and
60
+ returns an exit status the resolver cannot map to a named outcome
61
+ (``Outcome::Failed``, see the table below) -- the oracle's own
62
+ ``validation_outcome_exit_code`` (`crates/tan-cli/src/commands/validate.rs:
63
+ 54-62`) sends that ONE case to ``RuntimeFailure`` (1) while every other
64
+ non-clean outcome already goes to ``ValidationFailure`` (2). alp-sdk-vscode
65
+ renders exit 2 as severity "warning" and exit 1 as "error", so that one
66
+ oracle case alone painted a genuinely-failing project red in the IDE --
67
+ indistinguishable from `tan` itself crashing.
68
+
69
+ This port deliberately does NOT mirror that one oracle case. "The validator
70
+ could not produce a verdict" is still the validator's verdict, in every
71
+ shape it takes here -- GENERALLY, and now that #376 has landed the spawn
72
+ path, that generality is what carries the decision: every reachable
73
+ ``OUTCOME_FAILED`` (an exit status outside the 0-5 range the resolver
74
+ names, a validator that crashed with a traceback, a validator that ran past
75
+ [`VALIDATOR_TIMEOUT_S`]) emits ``ExitCode.VALIDATION_FAILURE`` (2), not
76
+ ``RuntimeFailure`` (1) -- do not "fix" that back to oracle parity; that
77
+ parity is the bug #262 fixes.
78
+ A genuine `tan`-side crash is unaffected by this decision and keeps its own
79
+ exit code: an unexpected exception that escaped tan's own code stays
80
+ ``ExitCode.INTERNAL_FAILURE`` (5). **tan-cli#498 defect 3 removed the other
81
+ case that used to sit here** -- a ``board.yaml`` tan could not READ (a
82
+ directory, a cp1252 file, a permission error) is now
83
+ ``validate.board-yaml-unreadable`` at exit **2**, because it is the user's
84
+ file to fix, not a tan crash, and because the identical file on the spawn
85
+ path already answered exit 2 (measured, both paths). See the guard itself
86
+ for the full argument; the spawn-LAUNCH I/O error specifically
87
+ (the subprocess could not even be started -- no interpreter on PATH, the
88
+ script unreadable) is ``validate.spawn-failed`` at
89
+ ``ExitCode.RUNTIME_FAILURE`` (1) -- the "generic runtime failure (e.g. I/O
90
+ or subprocess error)" `crates/tan-cli/src/exit.rs` itself names that code
91
+ for, and the ONE case #262's own text carved out for it. Only the
92
+ validator's-verdict exit code moves; tan's own crash exit codes do not.
93
+ A timeout is deliberately on the other side of that line: the child DID
94
+ start, so it is a verdict that never arrived (2), not a launch failure (1).
95
+
96
+ Exit 5 is wrong on every one of these paths regardless: reporting a
97
+ validator that could not answer as ``InternalFailure`` would tell CI/the
98
+ extension this is a tan crash, the same mistake the Rust comment on the
99
+ offline path (below) was written to stop repeating.
100
+
101
+ Re-measure before changing any of this. Do not infer the oracle's behaviour
102
+ from a report, a docstring, or ``crates/`` -- run the binary.
103
+
104
+ **The validator-exit-status -> outcome map**, measured on the oracle
105
+ (``target/debug/tan.exe``) with a contrived SDK (``scripts/alp_project.py``
106
+ marker + ``scripts/validate_board_yaml.py`` exiting a chosen code),
107
+ ``board.yaml`` present, ``--sdk-root`` pointed at it, no ``--offline``:
108
+
109
+ =============== =============================== ===
110
+ validator exit outcome rc
111
+ =============== =============================== ===
112
+ 0 clean 0
113
+ 1 schema-violation 2
114
+ 2 missing-preset 2
115
+ 3 hardware-revision 2
116
+ 5, 77 failed 1
117
+ =============== =============================== ===
118
+
119
+ So ``validate.failed`` is specifically the "anything outside the range the
120
+ resolver maps by number" case -- NOT every non-clean status, and in
121
+ particular not 2, 3, 4 or 5, which have their own named outcomes. A reader
122
+ must not infer "any nonzero -> failed" from this docstring. The ``rc``
123
+ column above is the ORACLE's, measured, and stays 1 for the ``failed`` row
124
+ -- that is a fact about ``target/debug/tan.exe``, not a decision, and must
125
+ not be edited to "fix" the table. This port's OWN rc for that row is 2, per
126
+ tan-cli#262 above -- a divergence recorded in prose here rather than in the
127
+ table because the table is a record of what was measured, not of this
128
+ port's choices.
129
+
130
+ **tan-cli#1262: this port's range is 0-5, so the table above is the
131
+ oracle's history, not this command's map.** The oracle was measured against
132
+ a contrived validator; the REAL one returns 4 and 5 as well, and those get
133
+ their own outcomes here rather than the oracle's ``failed`` -- see
134
+ [`_STATUS_OUTCOME`], which carries the measurement and the argument.
135
+
136
+ **The raw status is now carried too** (also #1262):
137
+ ``data.validatorExitStatus`` reports ``out.returncode`` verbatim on the
138
+ spawn path, so the ``.get(..., OUTCOME_FAILED)`` fallback stops being
139
+ lossy -- a future validator exit this table does not yet name still arrives
140
+ as a NUMBER a consumer can act on, not just as ``failed``. The key is
141
+ OMITTED, not ``null``, wherever no validator returned a status: the whole
142
+ ``--offline`` path, all three guards, a spawn-LAUNCH failure, and a timeout
143
+ (the child was killed, so ``TimeoutExpired`` carries no returncode).
144
+
145
+ ``validate.spawn-not-implemented`` is GONE as of #376 (the branch that
146
+ emitted it is), and its ``contract/issue-codes.json`` entry deleted rather
147
+ than marked ``retired``: it was ``reserved``/``consumer: none``, which the
148
+ registry's own header defines as the state where "renaming or dropping it
149
+ costs nothing on the wire", and it never existed in the Rust CLI at all, so
150
+ no pinned v0.4.1 binary can have taught a consumer to expect it.
151
+
152
+ **The spawn path, as ported.** Three guards, then the subprocess, then the
153
+ child's stderr mapped into the envelope:
154
+
155
+ * ``board.yaml`` must exist -- ``validate.board-yaml-missing``, checked
156
+ FIRST and on both paths (see the guard's own comment).
157
+ * the alp-sdk checkout must resolve -- ``validate.sdk-root-unresolved``,
158
+ through [`resolve_sdk_root_ladder`], the NARROW ladder the oracle resolves
159
+ ``validate`` with (that helper's own docstring names `validate` in the
160
+ thirteen). This is why ``--sdk-root`` is now READ rather than merely
161
+ accepted: an earlier revision of the option's help said "declared, not
162
+ consumed ... an arbitrary ``--sdk-root`` value changes nothing in the
163
+ envelope", which was true only because the oracle re-validates the flag
164
+ against the loader marker and an ARBITRARY path resolves to nothing.
165
+ ``resolve_sdk_root_ladder`` does NOT re-validate it -- it returns an
166
+ explicit flag verbatim (I-31, terminal-for-REPORTING) -- so this command
167
+ applies tan-cli#257/#258's own guard at the call site, exactly as
168
+ `build_cmd` and `run_cmd` do. Without it a ``--sdk-root`` pointing at an
169
+ empty directory reached the SPAWN, whose child died with ``python.exe:
170
+ can't open file '...\\scripts\\validate_board_yaml.py'``, exit 2 -- which
171
+ this command's status map reads as ``missing-preset``. MEASURED before the
172
+ fix: tan answered ``validate.missing-preset`` and named the customer's
173
+ BOARD for the operator's bad flag; the oracle answers
174
+ ``validate.sdk-root-unresolved`` on that same input, with no ``sdk`` block.
175
+ * the interpreter must be new enough to run the SDK scripts --
176
+ ``validate.python-too-old``, the oracle's own guard 3
177
+ (`crates/tan-cli/src/commands/validate.rs:124-129`, message body at
178
+ `crates/tan-cli/src/util.rs:192-202`). The scripts use
179
+ ``@dataclass(slots=True)``, so an older interpreter dies with a cryptic
180
+ ``dataclass() got an unexpected keyword argument 'slots'`` from inside
181
+ alp-sdk instead of an actionable refusal. TWO deliberate divergences, both
182
+ the port's established shape for this same guard elsewhere: the floor is
183
+ the resolved SDK's OWN declared ``metadata/bootstrap.json``
184
+ ``pythonMinVersion`` (`doctor_cmd.resolve_manifest_python_floor`, I-24)
185
+ rather than the oracle's compiled-in ``MIN_PYTHON = (3, 10)`` that can
186
+ drift from it, and the interpreter probed is [`_planner_python`]'s -- the
187
+ one that will actually be spawned two lines later -- not the bare PATH
188
+ ``python``/``python3`` the oracle probes and then also spawns. Both are
189
+ `generate_cmd`/`model_cmd`'s decisions, and this reuses
190
+ `generate_cmd._python_too_old` rather than becoming a fourth copy of it.
191
+
192
+ The argv is the oracle's, verbatim --
193
+ ``<python> <sdk>/scripts/validate_board_yaml.py --input <board>`` -- and
194
+ ``data.commandLine`` reports exactly that string (the offline path, and every
195
+ one of the three guards, keep reporting ``""``, which the two committed
196
+ conformance fixtures pin).
197
+
198
+ **The ``sdk`` block.** ``sdk: {root, sourceTier}`` is emitted on the spawn
199
+ path whenever a checkout actually resolved, and OMITTED (absent, never null)
200
+ otherwise. Measured across every reachable shape: present with
201
+ ``"sourceTier":"sdkRootFlag"`` for a valid ``--sdk-root`` and with
202
+ ``"discovery"`` for a sibling checkout -- including on the
203
+ ``board-yaml-missing`` refusal, where the SDK resolved and the board did not
204
+ -- and absent for ``--offline`` (with or without a resolvable checkout, and
205
+ with or without ``--sdk-root``), for the unresolved-SDK guard, and for a
206
+ ``--sdk-root`` that failed the marker check. So the rule is not "the spawn
207
+ path reports an SDK" but "the envelope reports whatever SDK this run
208
+ resolved", and ``--offline`` resolves none by construction -- it has no
209
+ subprocess to point anywhere.
210
+
211
+ The interpreter is [`_planner_python_resolution`] (wrapped by
212
+ [`_planner_python`]), NOT the bare ``python3``/``python`` the oracle
213
+ hardcodes: the child imports ``jsonschema``/``PyYAML``/``colorama``, which
214
+ live in the workspace venv `tan bootstrap` creates, and a bare PATH
215
+ interpreter without them exits 1 with a traceback -- the exact collision
216
+ `is_interpreter_crash` exists to disambiguate. Preferring the venv does not
217
+ remove that guard (the venv can still be broken), it just stops the common
218
+ case from taking it.
219
+
220
+ **tan-cli#652: a bare-interpreter crash names the real remedy.** On the
221
+ released, frozen `tan`, `sys.executable` is `tan` itself (unusable -- see
222
+ `_planner_python`'s own docstring), so before a successful `tan bootstrap`
223
+ has created a workspace venv there is nothing for `_planner_python` to
224
+ prefer and it falls back to the SYSTEM `python3`/`python`, which almost
225
+ never has `jsonschema` installed. That reproduces `is_interpreter_crash`'s
226
+ collision on every out-of-order invocation of a released binary, and used to
227
+ surface as a raw `ModuleNotFoundError: No module named 'jsonschema'` quoted
228
+ inside "Validation ended with outcome 'failed'." -- true, but naming a
229
+ Python module instead of the actual, fixable condition. A pip/from-source
230
+ install never reproduces this: `jsonschema` is one of `tan`'s OWN declared
231
+ dependencies (see `pyproject.toml`'s comment on it), so it lands on the
232
+ SAME interpreter a source install's `python3` already is. `_synthesised_
233
+ finding`'s `used_workspace_venv` flag is what tells the two apart: `False`
234
+ only when [`_planner_python_resolution`] itself found no workspace venv to
235
+ prefer, so a venv that resolved but is independently broken (a corrupted
236
+ `tan bootstrap`, not a missing one) still gets the generic message rather
237
+ than being told to re-run a bootstrap that already ran.
238
+
239
+ Two hardenings the oracle does not have, both load-bearing:
240
+
241
+ * **A timeout.** ``Command::output()`` has none, so an SDK validator that
242
+ wedges wedges `tan` -- and under ``--format json`` a consumer then gets no
243
+ envelope at all, which is indistinguishable from a slow project. Bounded by
244
+ [`VALIDATOR_TIMEOUT_S`]; the timeout is a ``failed`` outcome (exit 2), see
245
+ the #262 paragraph above.
246
+ * **ANSI stripping** (`_ANSI_RE`) -- defence, NOT the load-bearing repair an
247
+ earlier revision of this docstring claimed. That revision stated as
248
+ MEASURED fact that the child writes ``\x1b[31merror[ALP-B005]\x1b[0m: ...``
249
+ to stderr and that "the oracle therefore drops every rich diagnostic it
250
+ spawned the validator to collect". Re-measured, per this module's own rule,
251
+ and the two halves came apart:
252
+
253
+ - The PARSER half is true. Driven through the oracle with a stand-in
254
+ validator writing one ANSI-wrapped and one plain ``error[ALP-B*]`` header
255
+ on the same run, only the plain one reached ``issues[]``:
256
+ ``parse_rich_header`` anchors on the line START, so an escape in front of
257
+ ``error`` costs the whole block.
258
+ - The PREMISE is false. ``validate_board_yaml.py:43`` does render with
259
+ ``color=not args.no_color`` and ``alp_cli.diagnostic._use_color(True)``
260
+ does return True unconditionally -- but ``alp_cli.diagnostic`` calls
261
+ ``colorama.init()`` at import, which replaces ``sys.stderr`` with an
262
+ ``AnsiToWin32`` wrapper that STRIPS SGR escapes when the stream is not a
263
+ tty. Every spawn -- the oracle's and this one -- gives the child a pipe.
264
+ Captured bytes from a real ``render(..., color=True)`` through one:
265
+ ``b'error[ALP-B005]: msg\r\n --> board.yaml:1:1\r\n |\r\n...'``, no
266
+ ``\x1b`` anywhere. The colorama-ABSENT branch paints through a ``_Stub``
267
+ whose ``__getattr__`` returns ``""``, so it emits no escapes either.
268
+ There is no third branch, so the oracle does not in fact drop the real
269
+ SDK's rich diagnostics.
270
+
271
+ Kept anyway, at one regex per line: it covers exactly the conditions this
272
+ reasoning rests on ceasing to hold -- a renderer that stops going through
273
+ colorama, a ``FORCE_COLOR``-style override, a caller that hands the child a
274
+ pty. Still NOT "fixed" by passing ``--no-color``, for the unchanged reason:
275
+ an older SDK without that flag would argparse-error to exit 2, which this
276
+ command's own status map reads as ``missing-preset`` -- a wrong verdict is
277
+ worse than an escape sequence.
278
+
279
+ ``Severity::Suggestion`` (the oracle's severity for a standalone ``hint:``
280
+ line) is emitted here as ``note``. The oracle has no ``--format
281
+ diagnostic-v1``/``sarif``; this port does, and both vocabularies are
282
+ ``error|warning|note`` (``diagnostic-v1.schema.json``'s severity enum,
283
+ `_SARIF_LEVEL`). A literal ``"suggestion"`` would emit a document that fails
284
+ alp-sdk's own schema and raise ``KeyError`` on the SARIF path.
285
+
286
+ ``tan_core::validate::is_summary_line`` is deliberately NOT ported: in the
287
+ oracle its branch (``i += 1; continue``) is byte-identical to the loop's own
288
+ fall-through (``i += 1``), so it classifies nothing -- an unmatched line is
289
+ skipped either way. Porting it would be porting a no-op.
290
+
291
+ **A wrong-shaped board.yaml is the USER's problem, not a tan crash.** The Rust
292
+ carries a comment earned the hard way: routing a malformed file through
293
+ ``InternalFailure`` (exit 5) "told CI/the extension this was a tan crash" and
294
+ disagreed with the spawn path, which reports the identical file as exit 2
295
+ ``schema-violation``. So a file that parses as YAML but does not fit the model
296
+ is a *validation* failure -- exit 2 -- and the one place in this port where
297
+ ``ValidationFailure`` is genuinely the right code. (Contrast `tan build`, where a
298
+ malformed *plan* is exit 1: the consumer renders 2 as a warning and 1 as an
299
+ error, and an unbuildable plan is not a warning.)
300
+
301
+ The structural checks are deliberately about the CONTRACT's shape -- is there a
302
+ top-level ``os:``, is there a ``cores:`` block -- never about hardware. No SKU
303
+ list, no addresses, no pin names live here: the OS is derived from each core's
304
+ Cortex class by the SDK planner and is never selectable, so tan has no business
305
+ knowing which SKUs exist.
306
+ """
307
+
308
+ from __future__ import annotations
309
+
310
+ import json
311
+ import os
312
+ import re
313
+ import subprocess
314
+ from dataclasses import dataclass
315
+ from pathlib import Path
316
+ from typing import Any
317
+
318
+ import typer
319
+
320
+ from tan.commands.doctor_cmd import resolve_manifest_python_floor
321
+
322
+ # `_python_too_old` is IMPORTED, not re-spelled: `generate_cmd` and `model_cmd`
323
+ # already carry this same guard for this same reason (a spawned SDK script that
324
+ # needs `@dataclass(slots=True)`), and the whole point of guard 3 is that a
325
+ # floor and a message duplicated per command drift apart per command.
326
+ # `model_cmd`'s copy is that drift already begun -- its message dropped the
327
+ # oracle's own "(VS Code users can instead set alpSdk.pythonPath)" sentence. A
328
+ # third copy here would make it the pattern.
329
+ from tan.commands.generate_cmd import _python_too_old
330
+ from tan.commands.sdk_cmd import NO_SDK_NEXT_STEPS
331
+ from tan.core.sdk_discovery import with_sdk_search
332
+ # tan-cli#1031: this resolver MOVED to `tan.core.board_context` so `tan
333
+ # scaffold` could reuse it instead of growing a fourth project/board
334
+ # resolver. Aliased to the private name this module's call site (and
335
+ # `test_validate_command.py`'s prose) already spells; an alias is not a
336
+ # second definition, which `tests/gates/test_shared_helpers_have_one_
337
+ # definition.py` documents at length.
338
+ from tan.core.board_context import resolve_board_path as _resolve_board_path
339
+ from tan.core.global_flags import accept_global_flags
340
+ from tan.core.sdk_discovery import (
341
+ _planner_python_resolution,
342
+ resolve_sdk_root_ladder,
343
+ sdk_resolution_issues,
344
+ )
345
+ from tan.core.shapes import is_sdk_root, rejected_sdk_root_message
346
+ from tan.core.subprocess_env import spawn_env
347
+ from tan.core.uri_reference import (
348
+ cwd_base_uri_or_none,
349
+ is_absolute_path_reference,
350
+ path_to_uri_reference,
351
+ )
352
+ from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
353
+ from tan.exit_codes import ExitCode
354
+ from tan.output_format import FORMAT_HELP, ValidateOutputFormat
355
+ from tan.version import TAN_VERSION
356
+
357
+ #: `data.schemaVersion` for this command's payload -- the envelope payload's own
358
+ #: version, unrelated to `board.yaml`'s `schemaVersion:`.
359
+ DATA_SCHEMA_VERSION = "1"
360
+
361
+ #: Seconds the SDK validator may run before it is killed. Generous for the same
362
+ #: reason `generate_cmd.EMIT_TIMEOUT_S` is (a cold run imports the whole
363
+ #: orchestrator package and reads every metadata file for the SKU), and bounded
364
+ #: for the reason the oracle's unbounded `Command::output()` is not: a wedged
365
+ #: child under `--format json` produces no envelope at all, which a consumer
366
+ #: cannot tell from a slow project. Read through the module at call time, so a
367
+ #: test can shorten it.
368
+ VALIDATOR_TIMEOUT_S = 300
369
+
370
+ #: tan-cli#270: `tan validate` runs the SDK's board.yaml validator IN-PROCESS
371
+ #: (`tan.core.board_validator_run`). Setting this to `subprocess` pins the
372
+ #: earlier engine -- spawning `<sdk>/scripts/validate_board_yaml.py` -- as a
373
+ #: deliberate choice, like `TAN_GENERATE_EXECUTOR=subprocess`. It is the
374
+ #: reference side of the engine-parity test, and what `tan diff`'s cross-check
375
+ #: still shares (`VALIDATOR_SCRIPT`).
376
+ VALIDATE_ENGINE_ENV = "TAN_VALIDATE_ENGINE"
377
+
378
+
379
+ #: tan-cli#270: the alp-sdk commit the in-process validator was audited against
380
+ #: (the freshness gate asserts it equals `HAND_PORT_PINNED_SDK_COMMIT`).
381
+ PORTED_FROM_SDK_COMMIT = "84132b98a53a9f0e5ef9e7622a38c6d637439873"
382
+
383
+ #: Leading words of the finding `run_in_process_engine` writes for a crashed
384
+ #: port (kept for display/tests; `_Result.crashed` is the structured signal).
385
+ VALIDATOR_CRASH_PREFIX = "tan's built-in validator could not read this SDK"
386
+
387
+
388
+ def _bound_sdk_version(sdk_root: Path) -> str:
389
+ """`metadata/sdk_version.yaml`'s version, for the skew message."""
390
+ from tan.commands.sdk_cmd import parse_sdk_version_yaml
391
+
392
+ try:
393
+ text = (Path(sdk_root) / "metadata" / "sdk_version.yaml").read_text(encoding="utf-8")
394
+ except (OSError, UnicodeDecodeError):
395
+ return "(version unknown)"
396
+ return "v" + (parse_sdk_version_yaml(text) or "(unknown)")
397
+
398
+
399
+ def _subprocess_engine_requested() -> bool:
400
+ return os.environ.get(VALIDATE_ENGINE_ENV, "").strip().lower() == "subprocess"
401
+
402
+
403
+ def subprocess_engine_requested() -> bool:
404
+ """Public name for the engine choice, shared with `tan diff`."""
405
+ return _subprocess_engine_requested()
406
+
407
+
408
+ def _sdk_direction(sdk_root: Path) -> str | None:
409
+ """`"newer"` / `"older"` when git can order the bound checkout against the
410
+ commit the port was audited at; None when it cannot (not a git checkout,
411
+ commit unknown, git missing, or HEAD is exactly the audited commit with
412
+ locally modified sources). A hash mismatch alone has no direction."""
413
+ import subprocess
414
+
415
+ from tan.core.doctor_git import _resolve_git_executable
416
+ from tan.core.subprocess_env import spawn_env
417
+
418
+ git = _resolve_git_executable()
419
+ if git is None:
420
+ return None
421
+
422
+ def _git(*args: str) -> subprocess.CompletedProcess[str] | None:
423
+ try:
424
+ return subprocess.run( # noqa: S603
425
+ [git, "-C", str(sdk_root), *args],
426
+ capture_output=True, text=True, timeout=10, check=False, env=spawn_env(),
427
+ )
428
+ except (OSError, subprocess.SubprocessError):
429
+ return None
430
+
431
+ def _is_ancestor(ancestor: str, descendant: str) -> bool | None:
432
+ run = _git("merge-base", "--is-ancestor", ancestor, descendant)
433
+ return None if run is None else {0: True, 1: False}.get(run.returncode)
434
+
435
+ head = _git("rev-parse", "HEAD")
436
+ if head is None or head.returncode != 0 or head.stdout.strip() == PORTED_FROM_SDK_COMMIT:
437
+ return None
438
+ if _is_ancestor(PORTED_FROM_SDK_COMMIT, "HEAD"):
439
+ return "newer"
440
+ if _is_ancestor("HEAD", PORTED_FROM_SDK_COMMIT):
441
+ return "older"
442
+ return None
443
+
444
+
445
+ def skew_warning(sdk_root: Path) -> str | None:
446
+ """Message for `validate.sdk-validator-newer`, or None when the bound
447
+ checkout's validator sources are the ones the port was audited against."""
448
+ from tan.core.board_validator_skew import skewed_sources
449
+
450
+ skewed = skewed_sources(sdk_root)
451
+ if not skewed:
452
+ return None
453
+ version = _bound_sdk_version(sdk_root)
454
+ ported = PORTED_FROM_SDK_COMMIT[:8]
455
+ direction = _sdk_direction(sdk_root)
456
+ fallback = f"run with {VALIDATE_ENGINE_ENV}=subprocess to use the SDK's own validator."
457
+ if direction == "older":
458
+ return (
459
+ f"the bound alp-sdk ({version}) has validator sources older than the ones "
460
+ f"tan's built-in validator was ported from ({ported}): {', '.join(skewed)}. "
461
+ "The built-in validator may apply rules this SDK does not, so it can reject "
462
+ f"a board this SDK accepts -- {fallback}"
463
+ )
464
+ if direction == "newer":
465
+ return (
466
+ f"the bound alp-sdk ({version}) has validator sources "
467
+ f"newer than the ones tan's built-in validator was ported from "
468
+ f"({ported}): {', '.join(skewed)}. Rules added since are "
469
+ "not applied, so a clean verdict here may not match the SDK's own validator "
470
+ f"-- {fallback}"
471
+ )
472
+ return (
473
+ f"the bound alp-sdk ({version}) has validator sources that differ from the ones "
474
+ f"tan's built-in validator was ported from ({ported}): {', '.join(skewed)}. "
475
+ "It may be newer (rules not applied here) or older (rules this SDK lacks), so "
476
+ f"the verdict may not match the SDK's own validator -- {fallback}"
477
+ )
478
+
479
+
480
+ def run_in_process_engine(board_path: str, sdk_root: Path) -> tuple[int, str, _Result]:
481
+ """The default engine: `(status, stderr, result)`, crash-as-skew handling
482
+ included. Shared by `tan validate` and `tan diff` so both give one verdict."""
483
+ from tan.core.board_validator_run import run_board_validator
484
+
485
+ run = run_board_validator(board_path, sdk_root)
486
+ result = analyze_validator_output(run.status, run.stderr)
487
+ if _is_interpreter_crash(run.stderr):
488
+ # See the "Version skew" paragraph of the module docstring: a crash on a
489
+ # checkout that has moved past the audited commit is tan's gap, not a
490
+ # defect in the customer's board.
491
+ last = run.stderr.strip().splitlines()[-1]
492
+ result = _Result(
493
+ OUTCOME_FAILED,
494
+ (
495
+ _Finding(
496
+ "error",
497
+ f"{VALIDATOR_CRASH_PREFIX} "
498
+ f"(bound alp-sdk {_bound_sdk_version(sdk_root)}; the "
499
+ f"validator was ported from {PORTED_FROM_SDK_COMMIT[:8]}). "
500
+ "This is not a verdict on board.yaml -- retry with "
501
+ f"{VALIDATE_ENGINE_ENV}=subprocess to run the SDK's own "
502
+ f"validator. Underlying error: {last}",
503
+ ),
504
+ ),
505
+ crashed=True,
506
+ )
507
+ if result.outcome != OUTCOME_CLEAN and not result.findings:
508
+ result = _Result(
509
+ result.outcome,
510
+ (_synthesised_finding(result.outcome, run.stderr, used_workspace_venv=True),),
511
+ )
512
+ return run.status, run.stderr, result
513
+
514
+
515
+ #: `<sdk>/scripts/validate_board_yaml.py` -- the script the oracle spawns
516
+ #: (`crates/tan-cli/src/commands/validate.rs::run_spawn`). NOT
517
+ #: `python -m alp_cli.main validate`: that needs `<sdk>/scripts` on PYTHONPATH,
518
+ #: while a script path puts its own directory on `sys.path[0]` for free.
519
+ VALIDATOR_SCRIPT = ("scripts", "validate_board_yaml.py")
520
+
521
+ # The `--format` values this command accepts are declared ONCE, as
522
+ # `tan.output_format.ValidateOutputFormat` (tan-cli#403) -- not as a literal
523
+ # tuple here plus a hand-written membership check in [`validate`]'s body,
524
+ # which is how `--help`, `cli.py`'s root callback and all three
525
+ # shell-completion scripts came to name only `text` and `json` while this
526
+ # command really accepted four: nothing connected them to the list, so the two
527
+ # IDE-oriented formats stayed reachable but undiscoverable.
528
+ #
529
+ # `diagnostic-v1` and `sarif` are NOT part of the shared
530
+ # `Format::Text`/`Format::Json` enum every other command mirrors from
531
+ # `crates/tan-cli/src/cli.rs` -- there is no Rust precedent for them yet. They
532
+ # exist so alp-sdk's quality gate (`scripts/check_diagnostic_schema.py`) can
533
+ # point at `tan` instead of spawning
534
+ # `python -m alp_cli.main validate --format json`.
535
+ #
536
+ # WARNING -- `--format json` does NOT mean the same thing in the two CLIs.
537
+ # In alp-sdk (`scripts/alp_cli/validate.py:20,34`), `--format json` IS the
538
+ # diagnostic-v1 document. In tan, `--format json` is the envelope
539
+ # (`{command,ok,exitCode,project,data,issues}`; see `_emit` below) and the
540
+ # diagnostic-v1 document moved to its own `--format diagnostic-v1`. The
541
+ # obvious find-and-replace swap -- pointing the gate's argv at `tan` while
542
+ # keeping `"--format", "json"` -- validates the envelope against
543
+ # `diagnostic-v1.schema.json` (whose root is `additionalProperties: false`)
544
+ # and fails with something like "Additional properties are not allowed
545
+ # ('command', 'ok', 'exitCode', ...)", which names nothing about the real
546
+ # cause. The gate-side edit `check_diagnostic_schema.py` needs is therefore
547
+ # NOT a repoint of the same argv -- it must spawn
548
+ # `tan validate --offline --format diagnostic-v1`, not
549
+ # `tan validate --offline --format json`. `--format sarif` keeps its name
550
+ # unchanged in both CLIs, so only the `json` case silently diverges.
551
+
552
+ #: `diagnostic-v1.schema.json`'s own `schemaVersion` -- an integer `const: 1`,
553
+ #: unrelated to `DATA_SCHEMA_VERSION` above (a different document, a different
554
+ #: field, a different type).
555
+ _DIAGNOSTIC_SCHEMA_VERSION = 1
556
+
557
+ _SARIF_SCHEMA_URI = (
558
+ "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/"
559
+ "sarif-schema-2.1.0.json"
560
+ )
561
+
562
+ #: The SARIF `originalUriBaseIds` key `_sarif_document` declares for its
563
+ #: `sarif_base` parameter (tan-cli#1117, computed by
564
+ #: `tan.core.uri_reference.cwd_base_uri_or_none`). SARIF 2.1.0 SS3.4.4
565
+ #: (EXAMPLE 2, `"uriBaseId": "%srcroot%"`) notes a `uriBaseId` "can be any
566
+ #: string" and does not require the `%` wrapping, but its own example uses
567
+ #: it and this repo invents no other convention to match, so the same
568
+ #: spelling is followed.
569
+ _SARIF_URI_BASE_ID = "%CWD%"
570
+
571
+ #: Outcome strings. The first five are verbatim from
572
+ #: `tan_core::validate::Outcome::as_str`; the two `hardware-revision-*`
573
+ #: siblings are this port's own (tan-cli#1262 -- the frozen oracle has no
574
+ #: `Outcome` variant for either). The issue code is `validate.<outcome>`, so
575
+ #: these strings are wire contract.
576
+ OUTCOME_CLEAN = "clean"
577
+ OUTCOME_SCHEMA_VIOLATION = "schema-violation"
578
+ OUTCOME_MISSING_PRESET = "missing-preset"
579
+ OUTCOME_HARDWARE_REVISION = "hardware-revision"
580
+ #: tan-cli#1262. Named after the upstream constants they carry
581
+ #: (`EXIT_SDK_REVISION_UNKNOWN` = 4, `EXIT_SDK_REVISION_NOT_BUILDABLE` = 5)
582
+ #: rather than invented here, because the whole reason each has its OWN exit
583
+ #: code upstream is that a caller can act on it mechanically: alp-sdk's own
584
+ #: comments give the remedies as "pick a revision that exists" (4) and "pick a
585
+ #: revision whose status is buildable" (5), neither of which is exit 3's "pin a
586
+ #: different SDK, or change hw_rev".
587
+ OUTCOME_HARDWARE_REVISION_UNKNOWN = "hardware-revision-unknown"
588
+ OUTCOME_HARDWARE_REVISION_NOT_BUILDABLE = "hardware-revision-not-buildable"
589
+ OUTCOME_FAILED = "failed"
590
+
591
+ #: Validator exit status -> outcome. Rows 0-3 are verbatim from
592
+ #: `tan_core::validate::classify_validation_outcome`; rows 4 and 5 are this
593
+ #: port's own (tan-cli#1262). Anything else -- and a `None` status (killed,
594
+ #: never started) -- is `OUTCOME_FAILED`.
595
+ #:
596
+ #: **Reachability, MEASURED against alp-sdk v0.16.0 and `dev` at
597
+ #: `cfeafd148cb16d24a0e6c2feb7749769fec8f992`** (both identical on this point;
598
+ #: re-measure rather than trusting this line, and re-pin it when you do):
599
+ #: `scripts/validate_board_yaml.py` returns 0, 1, 3, 4 and 5 -- 3, 4 and 5
600
+ #: being `EXIT_SDK_REVISION_UNSUPPORTED`, `EXIT_SDK_REVISION_UNKNOWN` and
601
+ #: `EXIT_SDK_REVISION_NOT_BUILDABLE`, all three returned from its `main()`.
602
+ #: It never returns 2.
603
+ #:
604
+ #: **Exit 2's row is nonetheless LIVE, and the distinction is the whole
605
+ #: point of this paragraph**: these keys are the CHILD PROCESS's exit status,
606
+ #: not the script's return value. `main()` never returns 2, but the process
607
+ #: still exits 2 when the interpreter cannot open the script (MEASURED -- see
608
+ #: this module's docstring at the tan-cli#257/#258 guard, `python.exe: can't
609
+ #: open file '...\\scripts\\validate_board_yaml.py'`, exit 2, read here as
610
+ #: `missing-preset`) or when argparse rejects a flag (measured too, in the
611
+ #: `--no-color` paragraph of that same docstring). The #257/#258 guard below
612
+ #: closes only the `sdkRootFlag` tier; a stub checkout resolved through
613
+ #: `discovery` or a project pin, or a present-but-unreadable script, still
614
+ #: reaches this row. Do not re-derive it as dead: an earlier revision of this
615
+ #: very comment did, contradicting both docstring measurements above it.
616
+ #: Until #1262 rows 4 and 5 were absent, so both fell through to
617
+ #: `OUTCOME_FAILED` -- a code published as "produced no usable verdict ... a
618
+ #: crash" -- which made a real hw_rev refusal indistinguishable from `tan`
619
+ #: falling over and left alp-studio's pre-build gate no choice but to degrade.
620
+ _STATUS_OUTCOME = {
621
+ 0: OUTCOME_CLEAN,
622
+ 1: OUTCOME_SCHEMA_VIOLATION,
623
+ 2: OUTCOME_MISSING_PRESET,
624
+ 3: OUTCOME_HARDWARE_REVISION,
625
+ 4: OUTCOME_HARDWARE_REVISION_UNKNOWN,
626
+ 5: OUTCOME_HARDWARE_REVISION_NOT_BUILDABLE,
627
+ }
628
+
629
+ #: `Issue.severity` -> SARIF `level`, mirroring
630
+ #: `scripts/alp_cli/diagnostic_format.py:_SARIF_LEVEL`. A deliberate explicit
631
+ #: table, not a passthrough: an `Issue.severity` outside this set must raise
632
+ #: (KeyError) rather than silently emit a SARIF log whose `level` is not one
633
+ #: of the spec's three values. The spawn path now feeds it `warning` and
634
+ #: `note` as well as `error` -- see the module docstring on why the oracle's
635
+ #: fourth severity, `suggestion`, is emitted as `note` here.
636
+ _SARIF_LEVEL = {"error": "error", "warning": "warning", "note": "note"}
637
+
638
+ #: SGR escapes only -- everything `alp_cli.diagnostic.render` can emit through
639
+ #: colorama's `Fore`/`Style`. See the module docstring: the oracle's own spawn
640
+ #: leaves these in and then fails to parse its own child's output.
641
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
642
+
643
+ #: `^(error|warning|note)\[(ALP-[A-Z]\d+)\]:\s*(.+)` -- the header of a rich
644
+ #: diagnostic block (`tan_core::validate::parse_rich_header`). `(\S.*)` rather
645
+ #: than `(.+)` so a header whose message is whitespace-only does not match,
646
+ #: matching the Rust `if !message.is_empty()` after its `trim_start`.
647
+ #:
648
+ #: tan-cli#498 defect 2: the `ALP-[A-Z][0-9]+` run is now a CAPTURING group.
649
+ #: The oracle matches the same text and throws the code away
650
+ #: (`crates/tan-core/src/validate.rs:375-382` keeps severity + message only),
651
+ #: so every rich diagnostic reached a consumer as `validate-schema-violation`
652
+ #: with no way back to `docs/diagnostics/ALP-B005.md`. Diagnostic codes are on
653
+ #: this repo's verbatim-fidelity list; dropping one is not a formatting choice.
654
+ _RICH_HEADER_RE = re.compile(r"^(error|warning|note)\[(ALP-[A-Z][0-9]+)\]:\s*(\S.*)$")
655
+
656
+ #: `^\s+-->\s+\S+:(\d+):(\d+)` -- the location arrow. The trailing
657
+ #: `(?:\s|$)` reproduces `is_arrow_line`'s `rsplitn(3, ':')`: `a:1:2:xyz` is
658
+ #: NOT an arrow there (its last field is not numeric), and without the anchor
659
+ #: this regex would match its `a:1:2` prefix and disagree.
660
+ #:
661
+ #: The two number runs are CAPTURING since tan-cli#498 defect 2 -- the same
662
+ #: `line:col` the SDK renders and the oracle throws away. What is matched is
663
+ #: unchanged: adding a group changes no line's arrow-or-not verdict.
664
+ _ARROW_RE = re.compile(r"^\s+-->\s+\S+:([0-9]+):([0-9]+)(?:\s|$)")
665
+
666
+ #: `^\s+[|0-9^= ]` -- an indented source/underline/hint continuation line.
667
+ _BLOCK_CONTINUATION_RE = re.compile(r"^\s[\s|^=0-9]")
668
+
669
+ #: The caret underline inside a rich block (` | ^^^^^^^^^^`). The
670
+ #: whole line must be bar-then-carets, so a `^` that happens to appear in the
671
+ #: quoted SOURCE line above it cannot be mistaken for the underline. Its
672
+ #: length is `Diagnostic.span`, which is what turns an LSP range into a real
673
+ #: span instead of a zero-width point (tan-cli#498 defect 2).
674
+ _CARET_RE = re.compile(r"^\s*\|\s*(\^+)\s*$")
675
+
676
+ #: ` = hint: <text>` inside a rich block -- `Diagnostic.hint`, the field that
677
+ #: carries "did you mean 'E1M-AEN801'?". NOT reachable by `_HINT_RE` below,
678
+ #: which anchors on `hint:` at the start of a line and can never match the
679
+ #: `= ` prefix; the oracle consumes these lines as block padding and discards
680
+ #: them (tan-cli#498 defect 2).
681
+ _BLOCK_HINT_RE = re.compile(r"^\s*=\s*hint:\s*(\S.*?)\s*$")
682
+
683
+ #: ` = see: docs/diagnostics/ALP-B005.md` inside a rich block --
684
+ #: `Diagnostic.doc_url`, reported as `documentationUri`. Taken from the
685
+ #: validator's own output rather than rebuilt from the code: alp-sdk's
686
+ #: `_doc_url` honours `ALP_DIAG_BASE_URL`, so synthesising the path here would
687
+ #: contradict the child whenever that variable is set.
688
+ _BLOCK_SEE_RE = re.compile(r"^\s*=\s*see:\s*(\S.*?)\s*$")
689
+
690
+ #: `^(FAIL|WARN)\s+(.+)` -- the legacy line shape `validate_board_yaml.py`
691
+ #: still emits for its consistency failures (`FAIL consistency: ...`).
692
+ _FAIL_WARN_RE = re.compile(r"^(FAIL|WARN)\s+(\S.*)$")
693
+
694
+ #: `^\s{2,}\S` -- an indented continuation of a FAIL/WARN line.
695
+ _FAIL_CONTINUATION_RE = re.compile(r"^\s{2,}\S")
696
+
697
+ #: `^\s*(hint|suggestion|suggest):` (case-insensitive).
698
+ _HINT_RE = re.compile(r"^\s*(?:hint|suggestion|suggest):", re.IGNORECASE)
699
+
700
+ #: The header of an unhandled interpreter/environment crash. The validator
701
+ #: exits 1 both for a genuine schema violation AND when it dies importing
702
+ #: `jsonschema`, so this is the only thing that tells a broken validator
703
+ #: environment from a real "board.yaml is invalid" verdict (issue #38).
704
+ _TRACEBACK_HEADER = "Traceback (most recent call last):"
705
+
706
+ #: `ModuleNotFoundError: No module named 'jsonschema'` -- the exact line a
707
+ #: spawned SDK script dies on when it runs under the bare system interpreter
708
+ #: `_planner_python` falls back to before `tan bootstrap` has ever created a
709
+ #: workspace venv (tan-cli#652). Quotes may be single or double depending on
710
+ #: the interpreter/repr; the module name is matched generically (not pinned
711
+ #: to `jsonschema`) because the same collision reproduces for any of the
712
+ #: SDK's other declared imports (`yaml`, `colorama`, ...).
713
+ _MISSING_MODULE_RE = re.compile(r"ModuleNotFoundError: No module named ['\"]([\w.]+)['\"]")
714
+
715
+ #: Severity of a rich block, by its own header keyword. `note` is the oracle's
716
+ #: `Severity::Suggestion` arm.
717
+ _HEADER_SEVERITY = {"error": "error", "warning": "warning", "note": "note"}
718
+
719
+
720
+ @dataclass(frozen=True)
721
+ class _Finding:
722
+ """One thing the validator said, with everything it said ABOUT it.
723
+
724
+ tan-cli#498 defect 2. This used to be a bare `(severity, message)` tuple,
725
+ which is all the frozen oracle keeps -- so `error[ALP-B005]: ...` reached
726
+ the user as the message alone: no code to look up
727
+ `docs/diagnostics/ALP-B005.md` with, no `did you mean 'E1M-AEN801'?`, and
728
+ a zeroed `--format diagnostic-v1` range even though the SDK had supplied
729
+ `2:8`. The envelope's `issues[]` still carries `validate.<outcome>` as its
730
+ CODE -- that is wire contract, unchanged -- but the ALP code, hint and
731
+ documentation URI are no longer thrown away between the child's stderr and
732
+ the document a machine consumer reads.
733
+
734
+ `line`/`col`/`span` are the validator's own 1-BASED numbers (the same
735
+ convention `alp_cli.diagnostic` renders `--> path:line:col` from), and 0
736
+ means "not stated". Both exporters convert from this single 1-based
737
+ source, in opposite directions: `diagnostic-v1` is zero-based (LSP) and
738
+ SARIF is one-based (spec). Do not pre-convert either here.
739
+ """
740
+
741
+ severity: str
742
+ message: str
743
+ #: `ALP-B005` and friends, or `None` for a finding tan itself produced
744
+ #: (every offline structural check) or a legacy `FAIL`/`WARN` line.
745
+ alp_code: str | None = None
746
+ hint: str | None = None
747
+ #: The `= see:` target verbatim, e.g. `docs/diagnostics/ALP-B005.md`.
748
+ doc_uri: str | None = None
749
+ line: int = 0
750
+ col: int = 0
751
+ span: int = 0
752
+
753
+
754
+ @dataclass(frozen=True)
755
+ class _Result:
756
+ outcome: str
757
+ #: One `_Finding` per thing to report, in the order they must reach
758
+ #: `issues[]`. The offline checks only ever produce `error`; the spawn
759
+ #: path carries the validator's own severities (see `_HEADER_SEVERITY`
760
+ #: and `_severity_for_outcome`).
761
+ findings: tuple[_Finding, ...]
762
+ #: True when tan's in-process validator crashed, so board.yaml was never
763
+ #: judged (tan-cli#1523); `tan diff` branches on this, not on message text.
764
+ crashed: bool = False
765
+
766
+
767
+ class BoardShapeError(Exception):
768
+ """`board.yaml` parsed as YAML but does not fit the board model."""
769
+
770
+
771
+ def _load_yaml(text: str) -> Any:
772
+ """Parse YAML using PyYAML when present, else a minimal top-level reader.
773
+
774
+ PyYAML is a declared base dependency, so the ImportError branch below is
775
+ only a stale-venv fallback, not the normal offline path. It degrades to
776
+ reading only what the structural checks actually consult: which top-level keys exist and whether each is a scalar
777
+ or a block. That is enough to distinguish `som: <scalar>` from
778
+ `som:` + an indented mapping, which is exactly what the checks below ask.
779
+ """
780
+ try:
781
+ import yaml # noqa: PLC0415 (stale-venv fallback below)
782
+ except ImportError:
783
+ return _top_level_shape(text)
784
+ try:
785
+ return yaml.safe_load(text)
786
+ except Exception as err: # yaml.YAMLError and anything a loader raises
787
+ raise BoardShapeError(f"could not be parsed as YAML: {err}") from err
788
+
789
+
790
+ def _top_level_shape(text: str) -> dict[str, Any]:
791
+ """Map each unindented `key:` to a scalar string or a nested-block marker.
792
+
793
+ Deliberately not a YAML parser. It answers one question -- is this key a
794
+ scalar or does it open a block -- because that is all the structural checks
795
+ need when PyYAML is absent.
796
+ """
797
+ shape: dict[str, Any] = {}
798
+ pending: str | None = None
799
+ for raw in text.splitlines():
800
+ if not raw.strip() or raw.lstrip().startswith("#"):
801
+ continue
802
+ if raw[:1].isspace() or raw.lstrip().startswith("-"):
803
+ if pending is not None:
804
+ shape[pending] = {} # the key opened a block
805
+ pending = None
806
+ continue
807
+ pending = None
808
+ key, sep, rest = raw.partition(":")
809
+ if not sep:
810
+ continue
811
+ name = key.strip()
812
+ value = rest.strip()
813
+ if value:
814
+ shape[name] = value
815
+ else:
816
+ shape[name] = None
817
+ pending = name
818
+ return shape
819
+
820
+
821
+ #: `doc.get(key, _MISSING)` -- distinguishes an ABSENT key from one written
822
+ #: with no value (`cores:` on a line by itself, which PyYAML reads as `None`).
823
+ #: The two are different defects and get different messages.
824
+ _MISSING = object()
825
+
826
+
827
+ def validate_board_text(text: str) -> _Result:
828
+ """The offline structural validator. Pure: text in, outcome out.
829
+
830
+ **tan-cli#498 defect 1: the two structural checks below are UNCONDITIONAL.**
831
+ They used to sit behind `_effective_schema_version(doc) >= 2`, which no
832
+ conforming alp-sdk project can satisfy -- `scripts/alp_migrate` pins
833
+ `LATEST = 1` with an empty registry, `board.schema.json` calls an absent
834
+ `schemaVersion` "version 1 permanently", and 0 of the 100 `board.yaml`
835
+ files under `alp-sdk/examples` declare it (measured, alp-sdk `99e47476`).
836
+ Neither check ever ran, so `--offline` answered `clean`, exit 0, on boards
837
+ the SDK rejects -- on the very path `validate.sdk-root-unresolved`'s own
838
+ message recommends to a user with no checkout.
839
+
840
+ The gate was also WRONG about the SDK: nothing in `board.schema.json` is
841
+ conditioned on `schemaVersion`. Its ROOT carries `"required": ["som",
842
+ "cores"]` and `"not": {"required": ["os"]}` outright, so the v1-shaped
843
+ board the gate was protecting is the board the real validator refuses --
844
+ measured on the `contract/envelopes/validate-offline-clean` fixture's own
845
+ board.yaml (`som:` + `preset:`, no `cores:`), which
846
+ `scripts/validate_board_yaml.py` answers with `error[ALP-B001]: required
847
+ key 'cores' is missing`, exit 1.
848
+
849
+ **A deliberate divergence from the frozen oracle**, which carries the
850
+ identical dead gate (`crates/tan-core/src/validate.rs:282`), in the family
851
+ of tan-cli#262's. It costs that one conformance case, DECLARED in
852
+ `tests/conformance/test_contract_envelopes.py`'s `DELIBERATE_DIVERGENCE`
853
+ (with the full rationale) rather than re-recorded, for that map's own
854
+ reason: the golden is the cross-language contract the frozen Rust binary
855
+ is also held to.
856
+
857
+ Deliberately NOT added: a `som:` requirement (the schema has one too).
858
+ Making the shipped-dead checks reachable is the fix; growing this into a
859
+ second copy of `board.schema.json` is not -- ADR-0017 says the SDK owns
860
+ the schema.
861
+ """
862
+ doc = _load_yaml(text)
863
+ if doc is None:
864
+ raise BoardShapeError("the document is empty")
865
+ if not isinstance(doc, dict):
866
+ raise BoardShapeError(
867
+ f"the top level must be a mapping of keys, got {type(doc).__name__}"
868
+ )
869
+
870
+ som = doc.get("som")
871
+ if som is not None and not isinstance(som, dict):
872
+ raise BoardShapeError(
873
+ "`som:` must be a mapping carrying a `sku:` key, but a scalar was "
874
+ f"given ({som!r}). Write it as:\n som:\n sku: <SKU>"
875
+ )
876
+
877
+ messages: list[str] = []
878
+
879
+ # I-02: the OS is derived from each core's Cortex class and is never
880
+ # selectable, so a top-level `os:` is rejected outright. `in`, not
881
+ # `get(...) is not None`: `board.schema.json`'s `"not": {"required":
882
+ # ["os"]}` fires on the KEY, so a bare `os:` with no value is the same
883
+ # violation and used to slip through.
884
+ if "os" in doc:
885
+ messages.append(
886
+ "unknown key 'os': the OS is a property of each core, derived from "
887
+ "its Cortex class, and is never selectable at the top level. Move "
888
+ "it under `cores:`:\n cores:\n <core-id>:\n os: <os>"
889
+ )
890
+
891
+ cores = doc.get("cores", _MISSING)
892
+ if cores is _MISSING:
893
+ messages.append(
894
+ "required key 'cores' is missing. Name at least one core:\n"
895
+ " cores:\n <core-id>:\n app: ./src"
896
+ )
897
+ elif not isinstance(cores, dict):
898
+ # An EMPTY mapping is deliberately not flagged: the no-PyYAML reader
899
+ # (`_top_level_shape`) represents every block-opening key as `{}` and
900
+ # cannot see inside it, so failing on `{}` would refuse every real
901
+ # board on a host without PyYAML. Emptiness is the SDK's to catch.
902
+ #
903
+ # One asymmetry follows from that and is accepted: a valueless
904
+ # `cores:` reads as `None` under PyYAML (refused here) and as `{}`
905
+ # under the fallback (accepted). The fallback is a degraded reader by
906
+ # construction -- its own docstring says it answers scalar-vs-block
907
+ # and nothing else -- and under-refusing there is the right side to
908
+ # err on, since the SDK validator still sees the real document.
909
+ messages.append(
910
+ f"`cores:` must be a mapping of core id -> settings, but a "
911
+ f"{type(cores).__name__} was given ({cores!r})"
912
+ )
913
+
914
+ outcome = OUTCOME_CLEAN if not messages else OUTCOME_SCHEMA_VIOLATION
915
+ return _Result(outcome, tuple(_Finding("error", message) for message in messages))
916
+
917
+
918
+ # ───────────────────────── full (spawn) validation ─────────────────────────
919
+ #
920
+ # Port of `tan_core::validate`'s spawn half + `validate.rs::run_spawn`'s
921
+ # subprocess. See the module docstring for the two deliberate divergences
922
+ # (timeout, ANSI stripping) and the one deliberate omission (`is_summary_line`,
923
+ # a no-op in the oracle).
924
+
925
+
926
+ def classify_validator_status(status: int | None) -> str:
927
+ """Validator exit status -> outcome (`classify_validation_outcome`). A
928
+ `None` status -- the process was killed, or never started -- lands on
929
+ `failed` through the same default, exactly as the oracle's `_ =>` arm
930
+ does; on POSIX a signal death arrives here as a NEGATIVE returncode
931
+ instead, which is equally unmapped and equally `failed`."""
932
+ return _STATUS_OUTCOME.get(status, OUTCOME_FAILED)
933
+
934
+
935
+ def _severity_for_outcome(outcome: str) -> str:
936
+ """`severity_for_outcome`: a missing preset is a warning, everything else
937
+ an error. Applies to legacy `FAIL` lines only -- a rich block and a `WARN`
938
+ line each carry their own severity."""
939
+ return "warning" if outcome == OUTCOME_MISSING_PRESET else "error"
940
+
941
+
942
+ def parse_validator_stderr(stderr: str, severity: str) -> tuple[_Finding, ...]:
943
+ """The validator's stderr as [`_Finding`]s -- port of
944
+ `tan_core::validate::parse_validation_issues`, plus tan-cli#498 defect 2.
945
+
946
+ Three shapes are recognised, and anything else is dropped: a rich
947
+ `error[ALP-B*]` block (whose `-->` arrow and indented source/hint
948
+ continuation lines are consumed with it, so one block is one finding), a
949
+ legacy `FAIL`/`WARN` line (folding its own indented continuations in,
950
+ joined by TWO spaces exactly as the oracle does), and a standalone
951
+ `hint:`/`suggestion:` line.
952
+
953
+ **What the block's continuation lines are now READ for.** The oracle walks
954
+ them only to know where the block ends -- "block code/line/col are parsed
955
+ for correct line skipping but not retained" is its own comment -- so the
956
+ `ALP-Bxxx` code, the `= hint:` remedy and the `= see:` documentation path
957
+ were consumed and dropped on every real run. Measured against alp-sdk's
958
+ own `tests/fixtures/board_yaml_bad/ALP-B005-bad-sku.yaml`, the user was
959
+ told the SKU was wrong and shown neither the suggested `E1M-AEN801` nor
960
+ the code that finds `docs/diagnostics/ALP-B005.md`. The skipping is
961
+ unchanged (same lines consumed, one block still one finding); the fields
962
+ now travel on the `_Finding` instead of being thrown away.
963
+
964
+ ANSI escapes are stripped per line before any of that -- see the module
965
+ docstring; without it the rich-header match fails on every real run.
966
+ """
967
+ lines = [_ANSI_RE.sub("", raw) for raw in stderr.replace("\r\n", "\n").split("\n")]
968
+ findings: list[_Finding] = []
969
+ i = 0
970
+ while i < len(lines):
971
+ line = lines[i]
972
+ if not line.strip():
973
+ i += 1
974
+ continue
975
+
976
+ rich = _RICH_HEADER_RE.match(line)
977
+ if rich:
978
+ block_line = block_col = block_span = 0
979
+ hint: str | None = None
980
+ doc_uri: str | None = None
981
+ i += 1
982
+ # Only an arrow line opens a continuation run: a header on its own
983
+ # is a complete finding, and the next line may be another header.
984
+ if i < len(lines) and (arrow := _ARROW_RE.match(lines[i])):
985
+ block_line, block_col = int(arrow.group(1)), int(arrow.group(2))
986
+ i += 1
987
+ while i < len(lines) and _BLOCK_CONTINUATION_RE.match(lines[i]):
988
+ if (caret := _CARET_RE.match(lines[i])) and not block_span:
989
+ block_span = len(caret.group(1))
990
+ elif (found_hint := _BLOCK_HINT_RE.match(lines[i])) and hint is None:
991
+ hint = found_hint.group(1)
992
+ elif (see := _BLOCK_SEE_RE.match(lines[i])) and doc_uri is None:
993
+ doc_uri = see.group(1)
994
+ i += 1
995
+ findings.append(
996
+ _Finding(
997
+ _HEADER_SEVERITY[rich.group(1)],
998
+ rich.group(3).strip(),
999
+ alp_code=rich.group(2),
1000
+ hint=hint,
1001
+ doc_uri=doc_uri,
1002
+ line=block_line,
1003
+ col=block_col,
1004
+ span=block_span,
1005
+ )
1006
+ )
1007
+ continue
1008
+
1009
+ legacy = _FAIL_WARN_RE.match(line)
1010
+ if legacy:
1011
+ parts = [legacy.group(2).strip()]
1012
+ while i + 1 < len(lines) and _FAIL_CONTINUATION_RE.match(lines[i + 1]):
1013
+ i += 1
1014
+ parts.append(lines[i].strip())
1015
+ # WARN is a warning whatever the outcome; FAIL follows the outcome.
1016
+ findings.append(
1017
+ _Finding(
1018
+ "warning" if legacy.group(1) == "WARN" else severity,
1019
+ " ".join(parts),
1020
+ )
1021
+ )
1022
+ i += 1
1023
+ continue
1024
+
1025
+ if _HINT_RE.match(line):
1026
+ findings.append(_Finding("note", line.strip()))
1027
+ i += 1
1028
+ continue
1029
+
1030
+ i += 1
1031
+ return tuple(findings)
1032
+
1033
+
1034
+ def _is_interpreter_crash(stderr: str) -> bool:
1035
+ return any(line.lstrip().startswith(_TRACEBACK_HEADER) for line in stderr.splitlines())
1036
+
1037
+
1038
+ def analyze_validator_output(status: int | None, stderr: str) -> _Result:
1039
+ """`(outcome, findings)` for one validator execution -- port of
1040
+ `analyze_validation_result`, crash reclassification included: a traceback
1041
+ on exit 1 is a broken validator ENVIRONMENT (`failed`), never a verdict
1042
+ about the board (`schema-violation`)."""
1043
+ outcome = classify_validator_status(status)
1044
+ if outcome == OUTCOME_SCHEMA_VIOLATION and _is_interpreter_crash(stderr):
1045
+ outcome = OUTCOME_FAILED
1046
+ return _Result(outcome, parse_validator_stderr(stderr, _severity_for_outcome(outcome)))
1047
+
1048
+
1049
+ def _synthesised_finding(
1050
+ outcome: str, stderr: str, *, used_workspace_venv: bool = True
1051
+ ) -> _Finding:
1052
+ """The one finding a non-clean run with NOTHING parseable still owes the
1053
+ wire -- `to_cli_issues`' own synthesis, plus the last non-empty stderr
1054
+ line. The oracle stops at "Validation ended with outcome 'failed'.", which
1055
+ on the case that reaches this most often (a validator whose environment
1056
+ lacks `jsonschema`, so its whole stderr is a traceback the parser drops)
1057
+ tells the user nothing at all about what went wrong.
1058
+
1059
+ tan-cli#652: when `used_workspace_venv` is `False` -- `_planner_python`
1060
+ fell back to a bare PATH interpreter because no `tan bootstrap` workspace
1061
+ venv resolved for this project -- and the traceback names a missing
1062
+ module, the generic "Last line of validator output" message is replaced
1063
+ with one naming the actual, fixable cause (no workspace venv yet) instead
1064
+ of a raw `ModuleNotFoundError`. `used_workspace_venv` defaults to `True`
1065
+ so a caller that does not pass it (and every pre-#652 test) keeps
1066
+ today's generic message: this is opt-in enrichment of one specific,
1067
+ identifiable cause, not a new default finding for every crash.
1068
+
1069
+ A workspace venv that ITSELF lacks the module (a corrupted `tan
1070
+ bootstrap` run, not a missing one) does NOT take this branch --
1071
+ `used_workspace_venv` is `True` there, and the customer gets the generic
1072
+ message rather than being told to re-run a `tan bootstrap` that already
1073
+ ran. Telling those two apart is the whole reason this is a caller-supplied
1074
+ flag instead of a guess from the stderr text alone.
1075
+ """
1076
+ if not used_workspace_venv:
1077
+ missing = _MISSING_MODULE_RE.search(stderr)
1078
+ if missing is not None:
1079
+ return _Finding(
1080
+ "error",
1081
+ f"the SDK validator's interpreter has no `{missing.group(1)}` "
1082
+ "module, because no `tan bootstrap` workspace venv was found "
1083
+ "for this project -- tan ran it under the system interpreter "
1084
+ "instead, which does not have it. Run `tan bootstrap` first, "
1085
+ "then re-run this command.",
1086
+ )
1087
+ message = f"Validation ended with outcome '{outcome}'."
1088
+ tail = [line.strip() for line in _ANSI_RE.sub("", stderr).splitlines() if line.strip()]
1089
+ if tail:
1090
+ message = f"{message} Last line of validator output: {tail[-1]}"
1091
+ return _Finding("error", message)
1092
+
1093
+
1094
+ #: The position both exporters fall back to when NOTHING located the finding:
1095
+ #: every offline structural check (`board.yaml` parses fine; the finding is
1096
+ #: about the whole document), a legacy `FAIL`/`WARN` line, and any rich block
1097
+ #: whose `-->` arrow was absent. Zero satisfies
1098
+ #: `diagnostic-v1.schema.json`'s `required: [start, end]` honestly -- it is not
1099
+ #: a claim about *where* on the line, only that no better position is known.
1100
+ #:
1101
+ #: It is no longer the position of EVERY issue: since tan-cli#498 defect 2 a
1102
+ #: rich block keeps its arrow's `line:col` and its caret's span, which is what
1103
+ #: lets an IDE underline the offending value instead of the file's first
1104
+ #: character.
1105
+ _ZERO_POSITION = {"line": 0, "character": 0}
1106
+
1107
+
1108
+ def _lsp_range(finding: _Finding) -> dict[str, Any]:
1109
+ """`$defs/range` for one finding -- ZERO-based, the LSP convention
1110
+ `diagnostic-v1.schema.json` mandates and `alp_cli.diagnostic_format.
1111
+ _lsp_range` implements: `start = (line - 1, col - 1)`, `end` on the same
1112
+ line at `start.character + max(1, span)`.
1113
+
1114
+ A finding with no stated line (`line == 0`) collapses to
1115
+ [`_ZERO_POSITION`] rather than underflowing to `-1`, which
1116
+ `$defs/position`'s `minimum: 0` forbids."""
1117
+ if finding.line <= 0 or finding.col <= 0:
1118
+ return {"start": _ZERO_POSITION, "end": _ZERO_POSITION}
1119
+ start = {"line": finding.line - 1, "character": finding.col - 1}
1120
+ return {
1121
+ "start": start,
1122
+ "end": {
1123
+ "line": start["line"],
1124
+ "character": start["character"] + max(1, finding.span),
1125
+ },
1126
+ }
1127
+
1128
+
1129
+ def _diagnostic_code(issue: Issue, finding: _Finding) -> str:
1130
+ """The `code` for the diagnostic/SARIF wire: the validator's OWN
1131
+ `ALP-Bxxx` when it gave one, else the envelope code with its dot stripped
1132
+ (`diagnostic-v1`'s `code` pattern `^[A-Za-z][A-Za-z0-9_-]*$` forbids a
1133
+ dot -- that is a different wire, not a rename of the envelope code).
1134
+
1135
+ tan-cli#498 defect 2: every entry used to be `validate-schema-violation`,
1136
+ which made the document alp-sdk's `check_diagnostic_schema.py` is meant to
1137
+ repoint at tan strictly less informative than the one it replaces --
1138
+ alp-sdk's own `_diagnostic_to_json` emits `ALP-B003`/`ALP-B005` there."""
1139
+ return finding.alp_code or issue.code.replace(".", "-")
1140
+
1141
+
1142
+ def _issue_to_diagnostic(issue: Issue, finding: _Finding, board_path: str) -> dict[str, Any]:
1143
+ """One reported finding as one `$defs/diagnostic` entry.
1144
+
1145
+ `hint` and `documentationUri` are emitted when -- and only when -- the
1146
+ validator supplied them, mirroring `_diagnostic_to_json`'s own `if
1147
+ diag.hint`. tan still does NOT synthesise a `documentationUri` for its own
1148
+ codes: it ships no `docs/diagnostics/<code>.md` landing pages, so there is
1149
+ no honest URL to invent. What changed in tan-cli#498 is that the SDK's
1150
+ `= see:` line is no longer discarded on the way past -- that URL is the
1151
+ child's, not tan's, and it exists."""
1152
+ diagnostic = {
1153
+ # tan-cli#1097: rendered through `path_to_uri_reference`, not
1154
+ # `board_path` bare -- a filesystem PATH is not a URI reference (on
1155
+ # Windows this was `C:\w\proj\board.yaml`, which no editor matches
1156
+ # by document URI, so the diagnostic attached to nothing, silently).
1157
+ # Fixed at the exporter, here and at the SARIF `artifactLocation`
1158
+ # below -- not in the path resolver; see `tan.core.uri_reference`'s
1159
+ # module docstring for the relative-vs-absolute split. This closes
1160
+ # the defect for an ABSOLUTE board path; a RELATIVE `uri` here is
1161
+ # left as a legal relative reference with no base declared -- this
1162
+ # module makes no claim about how a consumer resolves it (see the
1163
+ # module docstring's own note on why an earlier attempt at that,
1164
+ # tied to SARIF's `originalUriBaseIds`, was reverted).
1165
+ "uri": path_to_uri_reference(board_path),
1166
+ "range": _lsp_range(finding),
1167
+ "severity": issue.severity,
1168
+ "code": _diagnostic_code(issue, finding),
1169
+ # `finding.message`, NOT `issue.message`: the envelope's message is the
1170
+ # HUMAN composition ([`_issue_message`] folds the code and hint into it
1171
+ # because `issues[].code` is `validate.<outcome>` and has nowhere else
1172
+ # to put them). Here they are structured fields of their own, so
1173
+ # repeating them inside `message` would hand a machine consumer the
1174
+ # same three facts twice and diverge from alp-sdk's own
1175
+ # `_diagnostic_to_json`, which emits the bare message.
1176
+ "message": finding.message,
1177
+ }
1178
+ if finding.hint:
1179
+ diagnostic["hint"] = finding.hint
1180
+ if finding.doc_uri:
1181
+ diagnostic["documentationUri"] = finding.doc_uri
1182
+ return diagnostic
1183
+
1184
+
1185
+ def _diagnostic_v1_document(
1186
+ reported: list[tuple[Issue, _Finding]], board_path: str
1187
+ ) -> dict[str, Any]:
1188
+ """`metadata/schemas/diagnostic-v1.schema.json`, mirroring
1189
+ `scripts/alp_cli/diagnostic_format.py:to_machine_json`'s shape.
1190
+
1191
+ tan-cli#1117 is deliberately scoped to `--format sarif` ONLY. LSP has no
1192
+ `originalUriBaseIds` equivalent -- a relative `uri` here is resolved by
1193
+ the CLIENT against whatever document root the editor/language server
1194
+ protocol negotiated, not by a base this document could declare -- so a
1195
+ relative `uri` stays exactly as tan-cli#1097 left it: unresolved,
1196
+ undocumented beyond this note. Resolution for the one consumer that
1197
+ matters, `alp-sdk-vscode`, falls to the WRAPPER
1198
+ (`src/alpCli/service.ts`), which shells this CLI and parses its output,
1199
+ not to an LSP client here parsing a protocol tan does not speak. Nothing
1200
+ in this function changed for tan-cli#1117; this docstring paragraph is
1201
+ the missing "say so" tan-cli#1117's own scope item asked for."""
1202
+ return {
1203
+ "schemaVersion": _DIAGNOSTIC_SCHEMA_VERSION,
1204
+ "tool": {"name": "tan", "version": TAN_VERSION},
1205
+ "diagnostics": [_issue_to_diagnostic(i, f, board_path) for i, f in reported],
1206
+ }
1207
+
1208
+
1209
+ def _sarif_region(finding: _Finding) -> dict[str, int]:
1210
+ """ONE-based SARIF `region`, the opposite convention to [`_lsp_range`] and
1211
+ a direct passthrough of the validator's own numbers plus the span width
1212
+ (`alp_cli.diagnostic_format._sarif_region`). An unlocated finding stays at
1213
+ `1:1`, as it was before tan-cli#498."""
1214
+ if finding.line <= 0 or finding.col <= 0:
1215
+ return {"startLine": 1, "startColumn": 1, "endLine": 1, "endColumn": 1}
1216
+ return {
1217
+ "startLine": finding.line,
1218
+ "startColumn": finding.col,
1219
+ "endLine": finding.line,
1220
+ "endColumn": finding.col + max(1, finding.span),
1221
+ }
1222
+
1223
+
1224
+ def _sarif_document(
1225
+ reported: list[tuple[Issue, _Finding]], board_path: str, sarif_base: str | None
1226
+ ) -> dict[str, Any]:
1227
+ """SARIF 2.1.0 (`runs[].results[]`), mirroring
1228
+ `scripts/alp_cli/diagnostic_format.py:to_sarif`. A separate artefact from
1229
+ `diagnostic-v1.schema.json` -- SARIF `region` is one-based by spec, so it
1230
+ does not reuse [`_lsp_range`].
1231
+
1232
+ `helpUri` follows `documentationUri` exactly: present on a rule whose
1233
+ validator named a `= see:` page, absent otherwise. tan invents none for
1234
+ its own codes (it has no landing pages), and `helpUri` is optional in the
1235
+ SARIF 2.1.0 schema.
1236
+
1237
+ tan-cli#1097 review measured that alp-sdk's own `to_sarif`/`_uri` --
1238
+ what "mirroring" above still means for shape -- used to emit
1239
+ `artifactLocation.uri` bare, with no scheme, before this function's `uri`
1240
+ handling diverged from it on purpose (see [`path_to_uri_reference`]).
1241
+ alp-sdk has since paid that same fix upstream (alp-sdk#1932, `43e5b2cb`,
1242
+ inside the `eff266b6` re-sync range this repo now pins) -- confirmed by
1243
+ reading `diagnostic_format.py`'s own `to_sarif`/`_uri` at that ref, not
1244
+ fixed here (a different repo, different release cadence).
1245
+
1246
+ tan-cli#1117: a RELATIVE `artifactLocation.uri` now carries a
1247
+ `uriBaseId` naming `_SARIF_URI_BASE_ID`, and the `run` declares that id
1248
+ in `originalUriBaseIds` pointing at `sarif_base` -- so a spec-conformant
1249
+ consumer resolves the reference the same way regardless of its own CWD,
1250
+ instead of falling back to it by luck. `originalUriBaseIds` is only
1251
+ ADDED when at least one location actually uses it: an absolute
1252
+ `--board-yaml` reference already resolves on its own, and SARIF 2.1.0
1253
+ SS3.4.4 says a location whose `uri` is absolute must NOT also carry a
1254
+ `uriBaseId` -- gated by [`is_absolute_path_reference`], not by sniffing
1255
+ the rendered string (see that function's own docstring for why sniffing
1256
+ is unsound for a driveless Windows path). A round-1 attempt at this PR
1257
+ tried the same shape and got the base itself wrong (anchored on `root`,
1258
+ missing a trailing slash) through code that could crash the command on a
1259
+ caller-supplied `--project` symlink loop; see `tan.core.uri_reference`'s
1260
+ module docstring for that account and how this version avoids both.
1261
+
1262
+ `sarif_base` is PRECOMPUTED -- see `cwd_base_uri_or_none`'s own
1263
+ docstring for why this function never calls it (or [`cwd_base_uri`])
1264
+ itself. `None` (CWD unavailable, or an absolute `board_path`, which never
1265
+ sets `base_needed` below regardless) reports the same thing `dev` gave
1266
+ before tan-cli#1117: a relative `uri` with no declared base."""
1267
+ rules: dict[str, dict[str, str]] = {}
1268
+ results = []
1269
+ base_needed = False
1270
+ for issue, finding in reported:
1271
+ code = _diagnostic_code(issue, finding)
1272
+ if code not in rules:
1273
+ rule = {"id": code}
1274
+ if finding.doc_uri:
1275
+ rule["helpUri"] = finding.doc_uri
1276
+ rules[code] = rule
1277
+ # tan-cli#1097: `artifactLocation` is a URI reference (SARIF 2.1.0),
1278
+ # never the bare filesystem path this used to emit -- see this
1279
+ # function's own docstring for what is, and is not, resolved by
1280
+ # this fix alone.
1281
+ artifact_location: dict[str, str] = {"uri": path_to_uri_reference(board_path)}
1282
+ if sarif_base is not None and not is_absolute_path_reference(board_path):
1283
+ artifact_location["uriBaseId"] = _SARIF_URI_BASE_ID
1284
+ base_needed = True
1285
+ results.append(
1286
+ {
1287
+ "ruleId": code,
1288
+ "level": _SARIF_LEVEL[issue.severity],
1289
+ # The bare message, for the same reason as `_issue_to_
1290
+ # diagnostic`'s: the code is `ruleId` and the landing page is
1291
+ # the rule's `helpUri`.
1292
+ "message": {"text": finding.message},
1293
+ "locations": [
1294
+ {
1295
+ "physicalLocation": {
1296
+ "artifactLocation": artifact_location,
1297
+ "region": _sarif_region(finding),
1298
+ }
1299
+ }
1300
+ ],
1301
+ }
1302
+ )
1303
+ run: dict[str, Any] = {
1304
+ "tool": {
1305
+ "driver": {
1306
+ "name": "tan",
1307
+ "informationUri": "https://github.com/alplabai/tan-cli",
1308
+ "version": TAN_VERSION,
1309
+ "rules": list(rules.values()),
1310
+ }
1311
+ },
1312
+ "results": results,
1313
+ }
1314
+ if base_needed:
1315
+ # tan-cli#1117: declared once per RUN, not per result -- every
1316
+ # relative `board_path` in one invocation names the same file, so
1317
+ # one base entry covers every location that references it.
1318
+ # `sarif_base` cannot be `None` here: `base_needed` is only ever
1319
+ # set inside the `sarif_base is not None` branch above.
1320
+ run["originalUriBaseIds"] = {_SARIF_URI_BASE_ID: {"uri": sarif_base}}
1321
+ return {
1322
+ "$schema": _SARIF_SCHEMA_URI,
1323
+ "version": "2.1.0",
1324
+ "runs": [run],
1325
+ }
1326
+
1327
+
1328
+ def _issue_message(finding: _Finding) -> str:
1329
+ """The human-facing message for one finding: the validator's text, led by
1330
+ its own `ALP-Bxxx` code and trailed by the remedy and landing page it
1331
+ named.
1332
+
1333
+ tan-cli#498 defect 2, the human half. The envelope's `issues[].code` stays
1334
+ `validate.<outcome>` (wire contract, registered in
1335
+ `contract/issue-codes.json`), which is why the diagnostic code has to
1336
+ travel in the MESSAGE for a `--format text`/`json` reader: without it the
1337
+ user is told the SKU is wrong and given neither the suggestion nor
1338
+ anything to look `docs/diagnostics/ALP-B005.md` up by. Laid out as
1339
+ alp-sdk's own renderer lays it out, minus the severity word (already an
1340
+ `Issue` field) and the source excerpt (tan has not read the file)."""
1341
+ message = (
1342
+ f"{finding.alp_code}: {finding.message}" if finding.alp_code else finding.message
1343
+ )
1344
+ if finding.hint:
1345
+ message += f"\n hint: {finding.hint}"
1346
+ if finding.doc_uri:
1347
+ message += f"\n see: {finding.doc_uri}"
1348
+ return message
1349
+
1350
+
1351
+ def _emit(
1352
+ *,
1353
+ output_format: ValidateOutputFormat,
1354
+ root: str,
1355
+ board_path: str,
1356
+ outcome: str,
1357
+ issues: list[Issue],
1358
+ exit_code: ExitCode,
1359
+ command_line: str = "",
1360
+ sdk: SdkInfo | None = None,
1361
+ findings: tuple[_Finding, ...] | None = None,
1362
+ # tan-cli#1262: the validator's RAW exit status, for
1363
+ # `data.validatorExitStatus`. `None` means "no validator returned a
1364
+ # status", and the key is then OMITTED rather than emitted as `null` --
1365
+ # the same absent-not-null rule `sdk` follows. `None` is unambiguous here
1366
+ # in a way it is not in `classify_validator_status` (whose `None` is the
1367
+ # oracle's `Option<i32>`): `subprocess.run` yields an `int` returncode on
1368
+ # every platform, so this stays `None` only when none completed.
1369
+ # The value is that returncode VERBATIM, which on POSIX is NEGATIVE for a
1370
+ # signal death (`-9` for SIGKILL) despite the field being named for an
1371
+ # exit status -- the same `-N` `classify_validator_status`'s docstring
1372
+ # already calls out as equally unmapped and equally `failed`.
1373
+ validator_status: int | None = None,
1374
+ # tan-cli#1117 review round 3: NO DEFAULT, deliberately -- see
1375
+ # `cwd_base_uri_or_none`'s own docstring for why this is precomputed
1376
+ # rather than read here. A dropped `sarif_base=` kwarg at any of the
1377
+ # three call sites below is a `TypeError` at the call, not a silently
1378
+ # base-less document.
1379
+ sarif_base: str | None,
1380
+ ) -> None:
1381
+ # tan-cli#478 review: `issues` may now carry the SDK-resolution pair
1382
+ # (`sdk.project-pin-unresolved`, `sdk.global-default-foreign-project`)
1383
+ # alongside this command's own findings. Those belong in the ENVELOPE and
1384
+ # nowhere else here:
1385
+ #
1386
+ # * `data.issueCount` reads as "how many findings does this board have";
1387
+ # counting a host fact made a CLEAN board report `outcome: "clean"`,
1388
+ # `exitCode: 0`, `issueCount: 1`.
1389
+ # * `--format diagnostic-v1` / `--format sarif` are ported alp-sdk
1390
+ # documents whose every entry is anchored at `board.yaml` 1:1. A CI job
1391
+ # uploading the SARIF would annotate line 1 of the customer's
1392
+ # board.yaml with "the machine-global default SDK was last set by ..."
1393
+ # -- a fact about the host, presented as a finding about the file.
1394
+ # * the tan-cli#350 text verdict keyed off `len(issues) == 1`, so a
1395
+ # prepended warning silently restored the "validate: validation
1396
+ # failure" wording that issue removed for the nothing-was-checked case.
1397
+ #
1398
+ # Split once, here, rather than at each of the four readers below.
1399
+ reportable = [issue for issue in issues if not issue.code.startswith("sdk.")]
1400
+
1401
+ # tan-cli#498 defect 2: `findings` is the RICH form of that same list, 1:1
1402
+ # with `reportable` and in the same order -- the diagnostic-v1/SARIF
1403
+ # documents read it for the `ALP-Bxxx` code, hint, documentation URI and
1404
+ # source range that `Issue` has nowhere to carry. A caller with nothing
1405
+ # richer to say (every `fail()` refusal) passes none, and each issue
1406
+ # stands in for its own location-less finding.
1407
+ #
1408
+ # Paired against `reportable`, NOT against `issues`, and that is the whole
1409
+ # interaction between #478 and #498: the callers below prepend
1410
+ # `sdk_context_issues` to `issues` while `findings=result.findings` counts
1411
+ # only the validator's own, so `zip(issues, findings)` would slide every
1412
+ # ALP code, hint and source range one position off the issue it describes
1413
+ # the moment a `.alp/sdk-path` pin misses. Filter first, then pair.
1414
+ if findings is None:
1415
+ findings = tuple(_Finding(issue.severity, issue.message) for issue in reportable)
1416
+ reported = list(zip(reportable, findings))
1417
+
1418
+ # Built for TEXT/JSON only (tan-cli#799): `Envelope.__init__` appends the
1419
+ # tan-cli#407 `sdk.discovery-divergent` warning at the shared seam
1420
+ # (`_with_sdk_divergence`). The three TEXT verdicts below loop `issues`
1421
+ # directly, and used to loop the PRE-envelope list -- built only inside
1422
+ # the JSON branch before -- so a seam-appended divergence issue reached
1423
+ # `--format json` and stayed silent on the default channel. DIAGNOSTIC_V1
1424
+ # and SARIF are ported documents that read neither `data` nor `envelope`
1425
+ # (see the two branches below), so building either for them would only
1426
+ # pay for `_with_sdk_divergence`'s filesystem ladder walk and discard the
1427
+ # result -- skip both there.
1428
+ if output_format in (ValidateOutputFormat.JSON, ValidateOutputFormat.TEXT):
1429
+ data = {
1430
+ "schemaVersion": DATA_SCHEMA_VERSION,
1431
+ "outcome": outcome,
1432
+ "issueCount": len(reported),
1433
+ # The validator command line that actually ran, or `""` -- which
1434
+ # every guard and the whole offline path keep, and which the two
1435
+ # committed conformance fixtures pin.
1436
+ "commandLine": command_line,
1437
+ # `data.boardYamlPath` is UNTOUCHED by tan-cli#236 -- it names
1438
+ # where tan looked even on the missing-board refusal below; only
1439
+ # `project.boardYaml` (Rust's starkest instance of the bug: the
1440
+ # refusal one line below says the file does not exist, in the
1441
+ # same envelope that used to still name it) is
1442
+ # existence-filtered.
1443
+ "boardYamlPath": board_path,
1444
+ }
1445
+ # tan-cli#1262. Set only when a validator actually returned a status,
1446
+ # ABSENT otherwise -- never `null`. Conditional rather than nullable
1447
+ # because the offline path spawns nothing, so a `null` there would
1448
+ # assert a run that did not happen, and because TWO of the three
1449
+ # committed `validate-offline-*` conformance goldens pin `data`
1450
+ # byte-for-byte (`validate-offline-clean` is in
1451
+ # `test_contract_envelopes.DELIBERATE_DIVERGENCE` and therefore
1452
+ # `xfail(strict=True)`, so its `data` is not compared at all -- it
1453
+ # would NOT have caught a leak here).
1454
+ if validator_status is not None:
1455
+ data["validatorExitStatus"] = validator_status
1456
+ envelope = Envelope(
1457
+ "validate",
1458
+ Project.resolved(root, board_path),
1459
+ data,
1460
+ issues,
1461
+ exit_code,
1462
+ # Absent, not null, when nothing resolved -- `Envelope` omits
1463
+ # the key on `None`. See the module docstring's `sdk` block
1464
+ # paragraph for the measured presence/absence matrix.
1465
+ sdk=sdk,
1466
+ )
1467
+ # Rebind: every branch below now sees whatever the seam appended.
1468
+ # Safe against `reportable`/`reported`/`data["issueCount"]` above,
1469
+ # all computed from the PRE-seam list -- a seam-appended issue's
1470
+ # code always starts with "sdk.", so it was already excluded from
1471
+ # `reportable` either way and none of those three needs
1472
+ # recomputing.
1473
+ issues = envelope.issues
1474
+
1475
+ if output_format == ValidateOutputFormat.JSON:
1476
+ emit(envelope)
1477
+ elif output_format == ValidateOutputFormat.DIAGNOSTIC_V1:
1478
+ # indent=2, matching scripts/alp_cli/validate.py:34's
1479
+ # `json.dumps(to_machine_json(collector), indent=2)` -- these two
1480
+ # formats are ported documents, not the envelope (which is
1481
+ # deliberately compact; see `tan.envelope.emit`'s `separators`).
1482
+ typer.echo(json.dumps(_diagnostic_v1_document(reported, board_path), indent=2))
1483
+ elif output_format == ValidateOutputFormat.SARIF:
1484
+ # indent=2, matching scripts/alp_cli/validate.py:36.
1485
+ typer.echo(json.dumps(_sarif_document(reported, board_path, sarif_base), indent=2))
1486
+ else:
1487
+ stream = typer.get_text_stream("stderr")
1488
+ if len(reportable) == 1 and reportable[0].code == "validate.board-yaml-missing":
1489
+ # tan-cli#350: this is not a VALIDATION failure -- there is no
1490
+ # board.yaml to validate, so nothing was checked and found
1491
+ # wrong. Every other non-clean outcome below still says
1492
+ # "validate: validation failure"; only this one issue code gets
1493
+ # its own verdict wording. `issues[0].message` (shared with
1494
+ # `--format json`'s `issues[].message`) already names where tan
1495
+ # looked and the remedy -- see the guard above.
1496
+ stream.write("validate: no board.yaml to validate\n")
1497
+ # tan-cli#478 review finding 6: this branch prints `reportable[0]`
1498
+ # alone -- deliberately, per tan-cli#350, to keep the verdict
1499
+ # wording narrow -- but the sdk.* advisories `reportable` filters
1500
+ # OUT of `issueCount`/sarif/diagnostic-v1 still belong on the
1501
+ # customer's screen; the other two branches below already print
1502
+ # them because they loop over the unfiltered `issues`.
1503
+ #
1504
+ # tan-cli#799: severity-prefixed (`f"{issue.severity}: ..."`),
1505
+ # matching how `clean`/`size`/`image`/`run` render the identical
1506
+ # `sdk.*` seam issues -- a bare `issue.message` here was the one
1507
+ # text-channel divergence `test_narrow_text_channel_carries_the_
1508
+ # divergence_warning` measured (no "warning:" prefix on
1509
+ # `sdk.discovery-divergent`). `reportable[0].message` just below
1510
+ # stays bare on purpose: that line is validate's own verdict
1511
+ # wording (#350/#498), never an `sdk.*` advisory.
1512
+ for issue in issues:
1513
+ if issue.code.startswith("sdk."):
1514
+ stream.write(f"{issue.severity}: {issue.message}\n")
1515
+ stream.write(f"{reportable[0].message}\n")
1516
+ elif len(reportable) == 1 and reportable[0].code == "validate.board-yaml-unreadable":
1517
+ # tan-cli#498 defect 3, the text half, and the same reasoning as
1518
+ # #350's line above: a board.yaml tan could not even DECODE was
1519
+ # never checked, so "validation failure" claims a verdict that was
1520
+ # never reached. The exit code (2) and issue code are what a
1521
+ # machine consumer reads; this line is what a human does.
1522
+ #
1523
+ # Keyed off `reportable`, and the sdk.* advisories printed first,
1524
+ # for tan-cli#478 review finding 6's reason applied to this
1525
+ # branch: a prepended `sdk.project-pin-unresolved` would otherwise
1526
+ # take `len(issues)` to 2 and silently restore the "validation
1527
+ # failure" wording this branch exists to replace.
1528
+ stream.write("validate: board.yaml could not be read\n")
1529
+ for issue in issues:
1530
+ if issue.code.startswith("sdk."):
1531
+ stream.write(f"{issue.severity}: {issue.message}\n")
1532
+ stream.write(f"{reportable[0].message}\n")
1533
+ elif outcome != OUTCOME_CLEAN:
1534
+ stream.write("validate: validation failure\n")
1535
+ # tan-cli#799: `sdk.*` seam issues (e.g. `sdk.discovery-divergent`)
1536
+ # get the same `f"{severity}: {message}"` rendering the other
1537
+ # four narrow-text commands use; validate's own `reportable`
1538
+ # findings (already accounted for in `outcome`/`issueCount` above
1539
+ # this render block) keep their bare wording unchanged.
1540
+ for issue in issues:
1541
+ if issue.code.startswith("sdk."):
1542
+ stream.write(f"{issue.severity}: {issue.message}\n")
1543
+ else:
1544
+ stream.write(f"{issue.message}\n")
1545
+ else:
1546
+ # Keyed off the OUTCOME, not off `issues` being non-empty: a
1547
+ # SPAWNED validator that exits 0 having printed warnings
1548
+ # (`validate_board_yaml.py` renders every diagnostic and only
1549
+ # RETURNS 1 for `collector.has_errors()`) is a clean board that
1550
+ # still has something to say. Reading `issues` instead -- which is
1551
+ # what this branch did while only the offline path could reach it,
1552
+ # where a clean result has no messages by construction -- printed
1553
+ # "validate: validation failure" over exit 0. The oracle's own
1554
+ # `spawn_text` keys off the outcome for exactly this reason.
1555
+ stream.write(f"validate: {board_path} is clean\n")
1556
+ # Same tan-cli#799 severity-prefix rule as the `validation
1557
+ # failure` branch above: only the `sdk.*` seam issues get it.
1558
+ for issue in issues:
1559
+ if issue.code.startswith("sdk."):
1560
+ stream.write(f"{issue.severity}: {issue.message}\n")
1561
+ else:
1562
+ stream.write(f"{issue.message}\n")
1563
+ raise typer.Exit(int(exit_code))
1564
+
1565
+
1566
+ def validate(
1567
+ offline: bool = typer.Option(
1568
+ False, "--offline", help="Run only the structural checks that ship in tan."
1569
+ ),
1570
+ project: str = typer.Option(
1571
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
1572
+ ),
1573
+ board_yaml: str = typer.Option(
1574
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
1575
+ ),
1576
+ sdk_root: str = typer.Option(
1577
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
1578
+ ),
1579
+ output_format: ValidateOutputFormat = typer.Option(
1580
+ ValidateOutputFormat.TEXT, "--format", help=FORMAT_HELP
1581
+ ),
1582
+ ) -> None:
1583
+ """Validate a board.yaml.
1584
+
1585
+ By default the SDK's board.yaml validator runs as a port inside tan. A
1586
+ bound alp-sdk newer than that port may add rules tan does not apply; tan
1587
+ warns (`validate.sdk-validator-newer`) when it can tell. Set the
1588
+ environment variable TAN_VALIDATE_ENGINE=subprocess to run the bound
1589
+ SDK's own scripts/validate_board_yaml.py instead.
1590
+
1591
+ `--sdk-root` must stay declared HERE, as a same-named local option, even
1592
+ though clap makes it `global = true` in Rust
1593
+ (`crates/tan-cli/src/cli.rs`): without it `cli._reorder_global_flags`
1594
+ relocates the flag to right after `validate` and Click rejects it there as
1595
+ unrecognised -- and the pre-subcommand position (`tan --sdk-root X
1596
+ validate`) is the one the extension actually uses
1597
+ (`alpCli/vscodeAdapter.ts`'s `withSdkRoot`). Since tan-cli#376 it is also
1598
+ READ -- and VALIDATED against the loader marker (tan-cli#257/#258) -- by
1599
+ the spawn path's own SDK guard; `--offline` still ignores it entirely,
1600
+ having no subprocess to point anywhere, and reports no `sdk` block.
1601
+ """
1602
+ root, board_path = _resolve_board_path(project, board_yaml)
1603
+
1604
+ # tan-cli#1117 review round 2: computed ONCE, here -- see
1605
+ # `cwd_base_uri_or_none`'s own docstring for why.
1606
+ sarif_base = cwd_base_uri_or_none()
1607
+
1608
+ # tan-cli#488 defect 8: the identical unguarded prologue `build_cmd.build`
1609
+ # had (see its own comment there) -- everything below this point is now
1610
+ # inside a try, so a raise from ANYWHERE in it (guard resolution, the SDK
1611
+ # ladder, the offline read, the spawn) still produces the
1612
+ # `validate.internal-failure` envelope below instead of a raw traceback
1613
+ # with empty stdout. `typer.Exit` is re-raised untouched -- `fail()`/`_emit()`
1614
+ # already raise it as their OWN normal control flow (every guard above and
1615
+ # below is a `fail(...)` call), and it subclasses `Exception` via
1616
+ # `click.exceptions.Exit(RuntimeError)`, so a bare `except Exception` here
1617
+ # would swallow a NORMAL exit and re-report it as `internal-failure`.
1618
+ try:
1619
+
1620
+ # Resolved BEFORE the board.yaml guard, though it is guard 2, because the
1621
+ # `sdk` block is not a guard result -- it reports what this RUN resolved,
1622
+ # and the oracle emits it on the board-yaml-missing refusal too (measured:
1623
+ # a valid `--sdk-root` + an empty directory answers
1624
+ # `validate.board-yaml-missing` WITH `sdk.sourceTier: "sdkRootFlag"`).
1625
+ # Guard ORDER is unchanged -- board.yaml still refuses first.
1626
+ #
1627
+ # `os.path.abspath`, not `Path(root)`: the ladder WALKS ANCESTORS, and
1628
+ # `Path(".").parents` is EMPTY -- the default `--project` would never
1629
+ # discover the sibling `../alp-sdk` every workspace layout puts there.
1630
+ # Lexical (not `.resolve()`) for the same reason `build_output.normalise`
1631
+ # is: a project reached through a symlink keeps the name the user typed.
1632
+ #
1633
+ # Skipped entirely under `--offline`: no subprocess, so no interpreter and
1634
+ # no checkout to report, and the oracle emits no `sdk` there even when one
1635
+ # would resolve (measured with a sibling checkout AND with an explicit
1636
+ # `--sdk-root`).
1637
+ resolved_sdk: Path | None = None
1638
+ sdk_info: SdkInfo | None = None
1639
+ # tan-cli#478. Declared OUTSIDE the `offline` branch: that path consults
1640
+ # no pointer, so this stays empty -- but `fail()` and the success `_emit`
1641
+ # both read it unconditionally below, so no future early return can drop
1642
+ # the pair (tan-cli#464 review).
1643
+ sdk_context_issues: list[Issue] = []
1644
+ if not offline:
1645
+ sdk_resolution = resolve_sdk_root_ladder(sdk_root, Path(os.path.abspath(root)))
1646
+ resolved_sdk = sdk_resolution.path
1647
+ sdk_tier = sdk_resolution.tier
1648
+ # tan-cli#257/#258, at this call site because `resolve_sdk_root_ladder`
1649
+ # deliberately does not do it: an explicit `--sdk-root` comes back
1650
+ # verbatim and unvalidated, which is right for a caller that only
1651
+ # REPORTS the tier and wrong for this one, which spawns out of it. An
1652
+ # unresolvable explicit root is no root at all -- guard 2 below then
1653
+ # gives the refusal the oracle gives, instead of the SDK's own
1654
+ # `can't open file ...validate_board_yaml.py` reaching the status map
1655
+ # as a verdict about the customer's board.
1656
+ if sdk_tier == "sdkRootFlag" and not is_sdk_root(resolved_sdk):
1657
+ resolved_sdk = None
1658
+ if resolved_sdk is not None:
1659
+ sdk_info = SdkInfo.from_resolution(str(resolved_sdk), sdk_resolution)
1660
+ # tan-cli#478: `validate` SPAWNS out of the resolved root, so a
1661
+ # foreign `globalDefault` means another project's schemas decided
1662
+ # `clean` for this board.yaml. Reported `ok: true`, `issues: []`.
1663
+ sdk_context_issues = sdk_resolution_issues(
1664
+ sdk_resolution.broken_project_pin,
1665
+ sdk_tier,
1666
+ sdk_resolution.foreign_global_default_for,
1667
+ )
1668
+
1669
+ def fail(code: str, message: str, exit_code: ExitCode) -> None:
1670
+ _emit(
1671
+ output_format=output_format,
1672
+ root=root,
1673
+ board_path=board_path,
1674
+ outcome=OUTCOME_FAILED,
1675
+ issues=[*sdk_context_issues, Issue(f"validate.{code}", "error", message)],
1676
+ exit_code=exit_code,
1677
+ sdk=sdk_info,
1678
+ sarif_base=sarif_base,
1679
+ )
1680
+
1681
+ if not Path(board_path).exists():
1682
+ # Ordered BEFORE the SDK guard and the spawn, deliberately -- the same
1683
+ # order `run_spawn` puts its own three guards in. (The SDK is RESOLVED
1684
+ # above this, which is not the same thing: resolving fills the `sdk`
1685
+ # block the oracle reports on this very refusal; refusing is what stays
1686
+ # ordered, and no guard fires before this one.) MEASURED against the
1687
+ # oracle (`target/debug/tan.exe`, tan 0.4.1-dev) in an empty directory:
1688
+ # exit 2, `validate.board-yaml-missing`, `outcome: "failed"` -- and the
1689
+ # oracle reaches this guard on both paths, `--offline` or not. Anything
1690
+ # that short-circuits above this check answers a DIFFERENT question in
1691
+ # the one case a brand-new user hits first: before #376 a "not ported
1692
+ # yet" stub did, and an SDK guard hoisted above it would answer "no
1693
+ # alp-sdk checkout" to a project that has no board.yaml either way.
1694
+ #
1695
+ # tan-cli#350 (DELIBERATE divergence -- the oracle is byte-identical
1696
+ # here, down to exit code and message): the oracle's own wording,
1697
+ # "board.yaml path could not be resolved or the file does not
1698
+ # exist.", names no remedy and, worse, is fronted in text mode by
1699
+ # "validate: validation failure" -- a VERDICT that implies something
1700
+ # was checked and found wrong. Nothing was validated; there is no
1701
+ # board.yaml to validate. This is the state every user is in before
1702
+ # `tan init`, and the old wording sent them looking for a defect in a
1703
+ # file that does not exist. The message below names WHERE tan looked
1704
+ # and the two remedies every sibling guard names for its own missing
1705
+ # input (`build` names `--sdk-root`, `doctor` names `tan init` /
1706
+ # `--board-yaml <path>` for this exact guard -- see
1707
+ # `doctor_cmd.py`'s `_board_yaml_check`). The exit code (2) and issue
1708
+ # CODE (`validate.board-yaml-missing`) are UNCHANGED: a
1709
+ # found-but-invalid board.yaml still exits 2 as
1710
+ # `validate.schema-violation` and still prints "validate: validation
1711
+ # failure" below -- the issue code is how a machine consumer (or a
1712
+ # human reading `--format json`) tells the two apart, since the exit
1713
+ # code alone does not.
1714
+ fail(
1715
+ "board-yaml-missing",
1716
+ f"no board.yaml found at {board_path} -- run `tan init` to create "
1717
+ "one, or pass --board-yaml <path> to point at an existing file.",
1718
+ ExitCode.VALIDATION_FAILURE,
1719
+ )
1720
+ return
1721
+
1722
+ #: The validator argv, for `data.commandLine`. Stays `""` on the offline
1723
+ #: path and on every guard -- nothing ran.
1724
+ command_line = ""
1725
+
1726
+ #: tan-cli#1262: the validator's raw exit status, for
1727
+ #: `data.validatorExitStatus`. Stays `None` -- so `_emit` OMITS the key
1728
+ #: -- everywhere no validator returned one: the whole offline path, all
1729
+ #: three guards, a spawn that failed to launch, and a timeout (the child
1730
+ #: was killed; `TimeoutExpired` carries no returncode).
1731
+ validator_status: int | None = None
1732
+ skew_message: str | None = None
1733
+
1734
+ if offline:
1735
+ try:
1736
+ text = Path(board_path).read_text(encoding="utf-8")
1737
+ except (OSError, UnicodeDecodeError) as err:
1738
+ # tan-cli#498 defect 3. Not a tan bug: an unreadable or
1739
+ # non-UTF-8 file is the user's to fix -- which this comment
1740
+ # already said while the code emitted
1741
+ # `validate.internal-failure` at exit 5, i.e. told CI and
1742
+ # alp-sdk-vscode that tan had CRASHED. Three measurements
1743
+ # against it: the identical file on the spawn path exits 2;
1744
+ # the envelope carried `data.outcome: "failed"` (a verdict)
1745
+ # beside `exitCode: 5`, contradicting this command's own
1746
+ # outcome->exit mapping below; and
1747
+ # `docs/bootstrap-manifest-unreadable.md` answers the same
1748
+ # input class for `tan bootstrap` as exit 2.
1749
+ #
1750
+ # 2, not 1: `validate.spawn-failed` owns exit 1 for "nothing
1751
+ # validated anything", which this resembles -- the tie is
1752
+ # broken by the SPAWN path answering exit 2 `validate.failed`
1753
+ # on the same cp1252 file (measured). One file must not be a
1754
+ # crash on one path and a verdict on the other.
1755
+ #
1756
+ # Its OWN code, not `validate.failed`: file-level problems
1757
+ # named before anything reads the board are a family
1758
+ # (`validate.board-yaml-missing` is the other), and a consumer
1759
+ # can act on "fix the encoding" without parsing prose. The
1760
+ # frozen oracle shares the exit-5 flaw; diverging is what
1761
+ # commit 49a5fde did for `tan sdk install`/`sdk switch` and
1762
+ # b4cef84 for the SIBLING BoardShapeError branch in the oracle
1763
+ # itself, left unapplied to the read case. A genuine tan crash
1764
+ # keeps exit 5 -- the `except Exception` backstop is unchanged.
1765
+ fail(
1766
+ "board-yaml-unreadable",
1767
+ f"could not read board.yaml: {err}",
1768
+ ExitCode.VALIDATION_FAILURE,
1769
+ )
1770
+ return
1771
+
1772
+ try:
1773
+ result = validate_board_text(text)
1774
+ except BoardShapeError as err:
1775
+ fail(
1776
+ "schema-violation",
1777
+ f"board.yaml is not valid: {err}",
1778
+ ExitCode.VALIDATION_FAILURE,
1779
+ )
1780
+ return
1781
+ except Exception as err: # never a bare traceback; the envelope is the contract
1782
+ fail(
1783
+ "internal-failure",
1784
+ f"validator failed unexpectedly: {err}",
1785
+ ExitCode.INTERNAL_FAILURE,
1786
+ )
1787
+ return
1788
+ else:
1789
+ if resolved_sdk is None:
1790
+ # The oracle's guard 2, message included in substance (its own
1791
+ # wording names only `--sdk-root`; the two other tiers this port's
1792
+ # ladder resolves are named here for the same reason
1793
+ # `trace_cmd.py`'s identical guard names them).
1794
+ #
1795
+ # tan-cli#381, FIFTH site: this line hardcoded "pin one with `tan
1796
+ # sdk switch <version|path>`" -- a subcommand that refuses outright
1797
+ # in this build (`sdk_cmd._run_not_ported`, `sdk.not-ported`). It
1798
+ # was not missed by the #305 sweep or the #381 sweep; it did not
1799
+ # EXIST when either ran. #376 wrote it here, from the oracle's own
1800
+ # (honest, there) wording, while #381 was cleaning the other four.
1801
+ # That is the whole mechanism, so the fix is not just this string:
1802
+ # `test_sdk_onboarding_dead_end.py`'s AST sweep now walks every
1803
+ # string literal under `python/tan/` and fails on the phrase, which
1804
+ # is what catches the SIXTH site before a human has to.
1805
+ #
1806
+ # tan-cli#497 defect 7: a REJECTED `--sdk-root` names the
1807
+ # value. The `--offline` sentence is kept on BOTH branches --
1808
+ # it is the one piece of remediation here that is not the flag
1809
+ # the caller just typed, and it is what an offline run needs.
1810
+ fail(
1811
+ "sdk-root-unresolved",
1812
+ rejected_sdk_root_message(
1813
+ sdk_root,
1814
+ "Nothing was validated. `tan validate --offline` runs the "
1815
+ "structural checks that need no SDK.",
1816
+ )
1817
+ if sdk_root
1818
+ else with_sdk_search(
1819
+ "alp-sdk root is unresolved. Use --sdk-root, place the project "
1820
+ f"near an alp-sdk checkout, or {NO_SDK_NEXT_STEPS}. "
1821
+ "`tan validate --offline` runs the structural checks that need "
1822
+ "no SDK.",
1823
+ os.path.abspath(root),
1824
+ ),
1825
+ ExitCode.VALIDATION_FAILURE,
1826
+ )
1827
+ return
1828
+
1829
+ if not _subprocess_engine_requested():
1830
+ # tan-cli#270: the DEFAULT engine. The SDK's validator is
1831
+ # ported in-process (`tan.core.board_validator_run`), so no
1832
+ # interpreter is probed and nothing is spawned -- the python
1833
+ # floor guard below guards a child interpreter that no longer
1834
+ # exists on this path. The run's (status, stderr) go through
1835
+ # the SAME `analyze_validator_output` a spawned script's did,
1836
+ # so the outcome map, issue codes and envelope are unchanged.
1837
+ # The customer's own file is read HERE, ahead of the engine, so
1838
+ # an undecodable or unreadable board.yaml is the user's to fix
1839
+ # (`validate.board-yaml-unreadable`, like `--offline`), not an
1840
+ # engine crash that blames the SDK.
1841
+ try:
1842
+ Path(board_path).read_text(encoding="utf-8")
1843
+ except (OSError, UnicodeDecodeError) as err:
1844
+ fail(
1845
+ "board-yaml-unreadable",
1846
+ f"could not read board.yaml: {err}",
1847
+ ExitCode.VALIDATION_FAILURE,
1848
+ )
1849
+ return
1850
+ validator_status, _stderr, result = run_in_process_engine(
1851
+ board_path, resolved_sdk
1852
+ )
1853
+ skew_message = skew_warning(resolved_sdk)
1854
+ else:
1855
+ script = os.path.join(str(resolved_sdk), *VALIDATOR_SCRIPT)
1856
+ # tan-cli#652: also captures whether this resolved a `tan
1857
+ # bootstrap` workspace venv or fell back to a bare PATH name --
1858
+ # the flag `_synthesised_finding` below needs to tell "this
1859
+ # interpreter is missing a dependency because no workspace venv
1860
+ # exists yet" apart from any other interpreter defect.
1861
+ python_binary, used_workspace_venv = _planner_python_resolution(
1862
+ os.path.abspath(root), str(resolved_sdk)
1863
+ )
1864
+
1865
+ # The oracle's guard 3 (`validate.rs:124-129`), the one #376 left out.
1866
+ # AFTER the SDK guard because both of its inputs come from the resolved
1867
+ # checkout: the floor is that checkout's own declared
1868
+ # `pythonMinVersion`, and `_planner_python` prefers its workspace venv.
1869
+ # BEFORE the spawn because the whole point is to replace alp-sdk's
1870
+ # `dataclass() got an unexpected keyword argument 'slots'` traceback --
1871
+ # which arrives as validator exit 1 WITH a traceback, i.e. `failed`
1872
+ # with the traceback's last line quoted at the user -- with a message
1873
+ # naming the actual defect. `command_line` is still `""` here: nothing
1874
+ # ran, exactly as on guards 1 and 2 and as the oracle reports.
1875
+ floor, _floor_source = resolve_manifest_python_floor(str(resolved_sdk))
1876
+ if (too_old := _python_too_old(python_binary, floor)) is not None:
1877
+ fail("python-too-old", too_old, ExitCode.VALIDATION_FAILURE)
1878
+ return
1879
+
1880
+ # Verbatim from `run_spawn`'s own `format!` -- this string is reported,
1881
+ # never re-parsed, so it is built beside the argv rather than from it.
1882
+ command_line = f"{python_binary} {script} --input {board_path}"
1883
+ try:
1884
+ out = subprocess.run(
1885
+ [python_binary, script, "--input", board_path],
1886
+ capture_output=True,
1887
+ text=True,
1888
+ encoding="utf-8",
1889
+ errors="replace",
1890
+ # The validator never reads stdin; without this a child that
1891
+ # somehow prompts would block forever behind the timeout.
1892
+ stdin=subprocess.DEVNULL,
1893
+ timeout=VALIDATOR_TIMEOUT_S,
1894
+ env=spawn_env(),
1895
+ check=False,
1896
+ )
1897
+ except subprocess.TimeoutExpired:
1898
+ # The child STARTED, so this is a verdict that never arrived, not a
1899
+ # launch failure: `failed` at exit 2, per tan-cli#262.
1900
+ result = _Result(
1901
+ OUTCOME_FAILED,
1902
+ (
1903
+ _Finding(
1904
+ "error",
1905
+ f"the SDK validator did not finish within "
1906
+ f"{VALIDATOR_TIMEOUT_S}s and was killed: {command_line}",
1907
+ ),
1908
+ ),
1909
+ )
1910
+ except (OSError, ValueError, subprocess.SubprocessError) as err:
1911
+ # The one RUNTIME_FAILURE (1) case #262 carved out: the subprocess
1912
+ # could not even be started (no interpreter on PATH, the script
1913
+ # unreadable). Nothing validated anything, so this is not a verdict.
1914
+ fail(
1915
+ "spawn-failed",
1916
+ f"could not run the SDK validator ({command_line}): {err}",
1917
+ ExitCode.RUNTIME_FAILURE,
1918
+ )
1919
+ return
1920
+ else:
1921
+ # tan-cli#1262: captured BEFORE the mapping consumes it. The
1922
+ # `.get(..., OUTCOME_FAILED)` fallback is the right default
1923
+ # for an exit this build does not name, but it must not be all
1924
+ # a consumer is left with -- an UNMAPPED status reaches the
1925
+ # wire as a NUMBER, not only as `failed`.
1926
+ validator_status = out.returncode
1927
+ result = analyze_validator_output(out.returncode, out.stderr)
1928
+ if result.outcome != OUTCOME_CLEAN and not result.findings:
1929
+ # `to_cli_issues`' synthesis: a non-clean run must never reach a
1930
+ # consumer as "exit 2, zero issues", which reads as no problem.
1931
+ # `used_workspace_venv=used_workspace_venv` (tan-cli#652) is what
1932
+ # lets this become "run `tan bootstrap` first" instead of a raw
1933
+ # `ModuleNotFoundError` when that is the actual cause.
1934
+ result = _Result(
1935
+ result.outcome,
1936
+ (
1937
+ _synthesised_finding(
1938
+ result.outcome,
1939
+ out.stderr,
1940
+ used_workspace_venv=used_workspace_venv,
1941
+ ),
1942
+ ),
1943
+ )
1944
+
1945
+
1946
+ issues = [
1947
+ *sdk_context_issues,
1948
+ *(
1949
+ Issue(f"validate.{result.outcome}", finding.severity, _issue_message(finding))
1950
+ for finding in result.findings
1951
+ ),
1952
+ ]
1953
+ reported_findings = result.findings
1954
+ if skew_message is not None:
1955
+ # Non-fatal: never changes the outcome or exit code. Kept 1:1 with
1956
+ # `issues` by appending the matching finding too.
1957
+ issues.append(Issue("validate.sdk-validator-newer", "warning", skew_message))
1958
+ reported_findings = (*result.findings, _Finding("warning", skew_message))
1959
+ exit_code = (
1960
+ ExitCode.SUCCESS
1961
+ if result.outcome == OUTCOME_CLEAN
1962
+ else ExitCode.VALIDATION_FAILURE
1963
+ )
1964
+ _emit(
1965
+ output_format=output_format,
1966
+ root=root,
1967
+ board_path=board_path,
1968
+ outcome=result.outcome,
1969
+ issues=issues,
1970
+ exit_code=exit_code,
1971
+ command_line=command_line,
1972
+ # tan-cli#1262. `None` -- key absent, not null -- everywhere no
1973
+ # validator returned a status, the offline path included.
1974
+ validator_status=validator_status,
1975
+ # `None` on the offline path (never resolved) -- the two committed
1976
+ # conformance fixtures are offline runs and stay `sdk`-less.
1977
+ sdk=sdk_info,
1978
+ # 1:1 with `issues`, built from the same list above -- the
1979
+ # diagnostic-v1/SARIF documents read the ALP code, hint,
1980
+ # documentation URI and range off these.
1981
+ findings=reported_findings,
1982
+ sarif_base=sarif_base,
1983
+ )
1984
+ except typer.Exit:
1985
+ raise
1986
+ except Exception as err: # never a bare traceback; the envelope is the contract
1987
+ _emit(
1988
+ output_format=output_format,
1989
+ root=root,
1990
+ board_path=board_path,
1991
+ outcome=OUTCOME_FAILED,
1992
+ issues=[
1993
+ Issue(
1994
+ "validate.internal-failure",
1995
+ "error",
1996
+ f"validator failed unexpectedly: {type(err).__name__}: {err}",
1997
+ )
1998
+ ],
1999
+ exit_code=ExitCode.INTERNAL_FAILURE,
2000
+ # Plain data by now -- see `cwd_base_uri_or_none`'s docstring.
2001
+ sarif_base=sarif_base,
2002
+ )
2003
+
2004
+
2005
+ # tan-cli#261: adds the seven oracle `GlobalArgs` flags this command was
2006
+ # still missing (`--all`/`--ci`/`--no-color`/`--non-interactive`/`--quiet`/
2007
+ # `--target`/`--verbose`) on top of `--board-yaml`, already declared and read
2008
+ # above; see `tan.core.global_flags`.
2009
+ validate = accept_global_flags(validate)