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,1020 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """`tan size` -- measure each Zephyr slice's `zephyr.elf` FLASH/RAM and compare
3
+ it to the SoM memory budget resolved from SoC metadata.
4
+
5
+ Port of `crates/tan-cli/src/commands/size.rs`: the IO/subprocess half. Every
6
+ measurement / budget / classification / render *decision* is pure in
7
+ `tan.core.size`, so this module is only file reads, one subprocess, and the
8
+ envelope.
9
+
10
+ **No `--os`, no `--backend`.** A slice's runtime arrives already resolved in the
11
+ manifest (I-01); this command reads `os` and never chooses it.
12
+
13
+ **tan learns no hardware fact.** The budget is whatever `<sdk>/metadata/**` says
14
+ -- SKU, variant order code, SRAM bank names and `tcm_kb` are all read out of
15
+ those files at runtime and none is spelled here (I-26). The SDK is READ, never
16
+ executed: no `alp_project.py`, no `alp_orchestrate`, no `west` (I-32 and its
17
+ anti-pattern #22 -- shelling the SDK gives a command a checkout dependency it
18
+ does not have, and no gate catches the first one). A missing SDK is not fatal:
19
+ the budget resolves `unknown` and measurement still runs.
20
+
21
+ Divergences from the retired v0.4.1 oracle, all deliberate.
22
+ `tests/parity/test_image_size_oracle.py` pinned them until tan-cli#269 deleted
23
+ the oracle axis with `crates/`. They did not go unpinned: the nesting bullet is
24
+ held tan-side by `tests/commands/test_size_command.py`'s
25
+ `test_i18_nested_west_output_is_measured_not_reported_not_built` and
26
+ `test_the_un_nested_path_still_wins_when_both_exist`, and the candidate ORDER
27
+ by `tests/core/test_system_manifest.py`'s
28
+ `test_i18_nesting_is_probed_after_the_plain_path_never_before`. The size-tool
29
+ timeout below is the one that really is unpinned:
30
+ - a `zephyr.elf` (or `rom.json`/`ram.json`) that landed in west's nested
31
+ `<build_dir>/build/` is FOUND rather than reported `not-built`. That is I-18:
32
+ `west build` is emitted with no `-d`, and the shipped binary reconciles the
33
+ nesting at BUILD time (`resolve_zephyr_artefact` writes the resolved paths
34
+ into the manifest it rewrites). This port's `tan build` does not yet write
35
+ that manifest, so the reconciliation happens on the read side -- see
36
+ `tan.core.system_manifest`. The un-nested path is always tried first, so
37
+ every input the oracle measures is measured identically.
38
+ - the size tool gets a timeout. The oracle has none; a wedged
39
+ `arm-zephyr-eabi-size` would hang `tan size` forever.
40
+ - a PyYAML that is declared but missing anyway (a frozen artifact built from a
41
+ stale venv) is a coded `size.manifest-invalid`, not a traceback.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import json
47
+ import os
48
+ import subprocess
49
+ from dataclasses import dataclass
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ import typer
54
+
55
+ from tan.core.shapes import is_file as _is_file
56
+ from tan.core.shapes import rejected_sdk_root_message
57
+ from tan.core.system_manifest import effective_build_root
58
+ from tan.commands.build_output import (
59
+ ManifestInvalid,
60
+ ManifestUnavailable,
61
+ ProjectContext,
62
+ load_manifest,
63
+ read_sdk_som_and_soc,
64
+ resolve_app_base,
65
+ resolve_build_root,
66
+ resolve_metadata_sdk_root,
67
+ resolve_project_context,
68
+ )
69
+ from tan.core.flash_plan import slice_should_flash
70
+ from tan.core.global_flags import accept_global_flags
71
+ from tan.core.metadata_schema import (
72
+ missing_schema_note,
73
+ soc_spec_schema_path,
74
+ som_preset_schema_path,
75
+ validate_document,
76
+ )
77
+ from tan.core.pending import is_pending_placeholder
78
+ from tan.core.som_schema_version import (
79
+ is_supported_som_schema_version,
80
+ skipped_presets_message,
81
+ )
82
+ from tan.core.sdk_discovery import sdk_resolution_issues
83
+ from tan.core.subprocess_env import spawn_env
84
+ from tan.core.size import (
85
+ MemoryBudget,
86
+ SliceSize,
87
+ budget_note_only,
88
+ build_size_report,
89
+ classify,
90
+ footprint_total,
91
+ over_budget_rows,
92
+ parse_berkeley_size,
93
+ render_table_lines,
94
+ resolve_budget,
95
+ resolve_variant,
96
+ sizes_from_elf_sections,
97
+ sram_banks,
98
+ unknown_budget_rows,
99
+ )
100
+ from tan.core.system_manifest import (
101
+ load_yaml_document,
102
+ slice_elf_candidates,
103
+ slice_footprint_dirs,
104
+ )
105
+ from tan.core.tool_lookup import resolve_tool
106
+ from tan.env import use_color
107
+ from tan.envelope import Envelope, Issue, Project, SdkDisclosure, SdkInfo, emit
108
+ from tan.exit_codes import ExitCode
109
+ from tan.output_format import FORMAT_HELP, OutputFormat, resolve_format
110
+
111
+ #: Size tools probed on PATH, most-specific first. The Zephyr SDK ships
112
+ #: `arm-zephyr-eabi-size`; an LLVM toolchain ships `llvm-size`; host binutils
113
+ #: ships `size`. All speak the same Berkeley columns.
114
+ SIZE_TOOLS = ("arm-zephyr-eabi-size", "llvm-size", "size")
115
+
116
+ #: Seconds before the size tool is abandoned and the next measurement rung tried.
117
+ #: The oracle spawns it unbounded; a hung tool there hangs the whole command, and
118
+ #: every subprocess in this port is bounded.
119
+ _SIZE_TOOL_TIMEOUT = 30
120
+
121
+ #: Streaming read size for the ELF, so a large image is not slurped whole just
122
+ #: to read its section headers. Section headers sit at the END of an ELF, so the
123
+ #: file does have to be read -- but bounded, and refused above this.
124
+ _MAX_ELF_BYTES = 512 * 1024 * 1024
125
+
126
+
127
+ def _find_on_path(command: str) -> str | None:
128
+ """Where `command` resolves on PATH -- the ABSOLUTE path, or `None`.
129
+
130
+ Port of `util::command_on_path`, hand-rolled rather than `shutil.which`,
131
+ which on Windows ALWAYS inserts `os.curdir` ahead of the search list (even
132
+ when an explicit `path=` is given). `where.exe` has the same behaviour, and
133
+ the oracle walks PATH by hand for exactly this reason: a project checked out
134
+ with its own `size.exe` at its root would otherwise be reported as
135
+ "available" and then SPAWNED. A project directory must never supply the
136
+ executable.
137
+
138
+ **tan-cli#567: it now returns the PATH, not a bool.** The walk above was
139
+ correct and its result was then thrown away -- `_sizes_from_size_tool` was
140
+ handed the bare identity and `subprocess.run` gave it straight back to
141
+ `CreateProcess`, whose documented search order puts the parent process's
142
+ current directory AHEAD of `%PATH%`. The hardened check found the real
143
+ `size.exe`; the spawn then ran the project's. Returning the resolved path
144
+ is what makes the check and the spawn agree, and it is the same
145
+ one-resolver rule tan-cli#510 established for the build spawn -- which is
146
+ why the walk itself is no longer written out here but delegated to
147
+ `tan.core.tool_lookup.resolve_tool`, the ONE copy (tan-cli#532).
148
+
149
+ `os.environ`, deliberately: unlike a build slice, `tan size` pins no `PATH`
150
+ of its own, so the environment this resolves against IS the one
151
+ `_sizes_from_size_tool`'s child inherits.
152
+ """
153
+ return resolve_tool(command, os.environ).resolved
154
+
155
+
156
+ def _sizes_from_size_tool(size_bin: str, elf: str) -> tuple[int, int] | None:
157
+ """Run the size tool on `elf` and parse its Berkeley output. `None` when the
158
+ tool fails to spawn, times out, exits non-zero, or its output does not parse
159
+ -- every one of which falls through to the next measurement rung rather than
160
+ failing the command.
161
+
162
+ `size_bin` is the ABSOLUTE path [`_find_on_path`] resolved, never a bare
163
+ identity (tan-cli#567). A bare name here would be handed back to the
164
+ platform's own resolver, which on Windows searches the parent process's
165
+ current directory before `%PATH%` -- so a bare `size_bin` is refused
166
+ outright rather than spawned. Refused, not "resolved here": a second
167
+ resolution at the spawn is exactly the drift tan-cli#510 was filed over,
168
+ and this rung failing closed simply falls through to the ELF-section-header
169
+ measurement, which needs no subprocess at all."""
170
+ if not Path(size_bin).is_absolute():
171
+ return None
172
+ try:
173
+ out = subprocess.run(
174
+ [size_bin, elf],
175
+ capture_output=True,
176
+ text=True,
177
+ encoding="utf-8",
178
+ errors="replace",
179
+ timeout=_SIZE_TOOL_TIMEOUT,
180
+ env=spawn_env(),
181
+ check=False,
182
+ )
183
+ except (OSError, subprocess.SubprocessError, ValueError):
184
+ return None
185
+ if out.returncode != 0:
186
+ return None
187
+ return parse_berkeley_size(out.stdout or "")
188
+
189
+
190
+ def _read_bytes(path: str) -> bytes | None:
191
+ try:
192
+ if os.stat(path).st_size > _MAX_ELF_BYTES:
193
+ return None
194
+ with open(path, "rb") as handle:
195
+ return handle.read()
196
+ except OSError:
197
+ return None
198
+
199
+
200
+ def _read_text(path: str) -> str | None:
201
+ try:
202
+ with open(path, encoding="utf-8", newline="") as handle:
203
+ return handle.read()
204
+ except (OSError, UnicodeDecodeError, ValueError):
205
+ return None
206
+
207
+
208
+ def _minus_off_budget_ram(
209
+ sizes: tuple[int, int], elf: str, spans: tuple[tuple[int, int], ...]
210
+ ) -> tuple[int, int]:
211
+ """`sizes` with the RAM that the ELF places inside *spans* removed; unchanged
212
+ when there are no spans or the section headers cannot be read."""
213
+ if not spans:
214
+ return sizes
215
+ raw = _read_bytes(elf)
216
+ if raw is None:
217
+ return sizes
218
+ whole = sizes_from_elf_sections(raw)
219
+ kept = sizes_from_elf_sections(raw, spans)
220
+ if whole is None or kept is None:
221
+ return sizes
222
+ return sizes[0], max(sizes[1] - (whole[1] - kept[1]), 0)
223
+
224
+
225
+ def _extract_sizes(
226
+ elf_candidates: list[str],
227
+ footprint_dirs: list[str],
228
+ size_bin: str | None,
229
+ off_budget_spans: tuple[tuple[int, int], ...] = (),
230
+ ) -> tuple[tuple[int, int] | None, str | None, str]:
231
+ """`(sizes, source_label, probed_elf)` via the best available source, in the
232
+ oracle's order: the size tool (only if the elf exists), then the elf's own
233
+ section headers, then `rom.json` + `ram.json`.
234
+
235
+ *off_budget_spans* (tan-cli#1402): writable sections linked inside one (SRAM0
236
+ under an ITCM link) are not counted as RAM. The size tool cannot see section
237
+ addresses, so its RAM column is corrected by the section reader's difference.
238
+
239
+ `probed_elf` is the path named in a `not-built` note -- the FIRST candidate,
240
+ which is the one the oracle would have named.
241
+ """
242
+ for elf in elf_candidates:
243
+ if not _is_file(elf):
244
+ continue
245
+ if size_bin is not None:
246
+ sizes = _sizes_from_size_tool(size_bin, elf)
247
+ if sizes is not None:
248
+ sizes = _minus_off_budget_ram(sizes, elf, off_budget_spans)
249
+ return sizes, "size-tool", elf_candidates[0]
250
+ # Middle rung: no size tool, or it failed to parse -- read the section
251
+ # headers directly so a present elf is still measured. The `pyelftools`
252
+ # label is kept for JSON parity with the retired command.
253
+ raw = _read_bytes(elf)
254
+ if raw is not None:
255
+ sizes = sizes_from_elf_sections(raw, off_budget_spans)
256
+ if sizes is not None:
257
+ return sizes, "pyelftools", elf_candidates[0]
258
+ for directory in footprint_dirs:
259
+ rom_text = _read_text(os.path.join(directory, "rom.json"))
260
+ ram_text = _read_text(os.path.join(directory, "ram.json"))
261
+ rom = None if rom_text is None else footprint_total(rom_text)
262
+ ram = None if ram_text is None else footprint_total(ram_text)
263
+ if rom is not None and ram is not None:
264
+ return (rom, ram), "rom/ram.json", elf_candidates[0]
265
+ return None, None, elf_candidates[0]
266
+
267
+
268
+ def _read_som_preset(
269
+ path: str, *, metadata_root: str | None = None, warnings: list[str] | None = None,
270
+ skipped: list[str] | None = None,
271
+ ) -> tuple[str, str | None] | None:
272
+ """`(silicon, silicon_variant)` from a SoM preset, or `None` when it cannot
273
+ be used.
274
+
275
+ Only the two fields the budget needs, plus the SAME `schema_version == 2`
276
+ guard `parse_som_preset` applies -- a preset the oracle refuses must be
277
+ `unreadable SoM preset for <sku>` here too, not silently half-read. `TBD` is
278
+ dropped to absent exactly as `str_clean` does, which is what variant
279
+ resolution wants.
280
+
281
+ *metadata_root*/*warnings* (tan-cli#964): when both are given, `root` --
282
+ the raw parsed document, already the shape `som-preset-v2.schema.json`
283
+ describes, unlike `presets_cmd.parse_som_preset`'s reshaped return value
284
+ -- is checked against it and every violation is appended to *warnings*.
285
+ `tan size`'s existing degrade-to-`unknown-budget` behaviour on a bad
286
+ preset is UNCHANGED by this (this function still returns exactly what it
287
+ always returned); the schema check only adds visibility into WHY a
288
+ budget came back unresolved, via the envelope's `issues[]`, never a new
289
+ way for this function to fail.
290
+ """
291
+ text = _read_text(path)
292
+ if text is None:
293
+ return None
294
+ try:
295
+ root = load_yaml_document(text)
296
+ except Exception: # noqa: BLE001 -- SystemManifestError or any PyYAML failure
297
+ return None
298
+ if not isinstance(root, dict):
299
+ return None
300
+ if not is_supported_som_schema_version(root.get("schema_version")):
301
+ return None
302
+ if metadata_root is not None:
303
+ schema_path = som_preset_schema_path(metadata_root)
304
+ if skipped is not None:
305
+ note = missing_schema_note(schema_path, source=path)
306
+ if note is not None:
307
+ skipped.append(note)
308
+ if warnings is not None:
309
+ warnings.extend(validate_document(root, schema_path, path))
310
+ silicon = _clean_str(root.get("silicon")) or ""
311
+ return silicon, _clean_str(root.get("silicon_variant"))
312
+
313
+
314
+ def _som_schema_version_issue(metadata_root: str | None, sku: str | None) -> Issue | None:
315
+ """tan-cli#1278: a `size.som-schema-version-skipped` warning when *sku*'s
316
+ preset parses but declares a `schema_version` this tan does not read.
317
+
318
+ `_read_som_preset` refuses it, and the row says only `unreadable SoM
319
+ preset for <sku>` -- the oracle's own words, kept for byte parity -- which
320
+ against a pre-v2 SDK is every SKU and names the wrong cause. This adds the
321
+ real one to `issues[]` without touching the row."""
322
+ if metadata_root is None or sku is None:
323
+ return None
324
+ path = os.path.join(metadata_root, "e1m_modules", f"{sku}.yaml")
325
+ text = _read_text(path)
326
+ if text is None:
327
+ return None
328
+ try:
329
+ root = load_yaml_document(text)
330
+ except Exception: # noqa: BLE001 -- unparseable is `unreadable`, not this
331
+ return None
332
+ if not isinstance(root, dict):
333
+ return None
334
+ version = root.get("schema_version")
335
+ if is_supported_som_schema_version(version):
336
+ return None
337
+ # Forward slashes, the path as the SDK root was resolved -- the same form
338
+ # `presets.som-schema-version-skipped` names its directory in.
339
+ shown = path.replace("\\", "/")
340
+ subject = f"the SoM preset for {sku} ({shown}) was not read, so its FLASH/RAM budget is unknown"
341
+ return Issue(
342
+ "size.som-schema-version-skipped", "warning", skipped_presets_message(subject, [version])
343
+ )
344
+
345
+
346
+ def _read_som_memory_map(path: str) -> list[dict]:
347
+ """The SoM preset's `memory_map:` entries, or `[]` when it declares none
348
+ (tan-cli#747).
349
+
350
+ Read here rather than widened into `read_sdk_som_and_soc`'s return tuple:
351
+ that walk is shared with `tan debug-config`, and growing a tuple every
352
+ caller destructures to give ONE of them a field it alone needs is how the
353
+ two readers drift apart -- the thing that walk exists to prevent. The
354
+ preset is small and already on this path, so re-reading it is cheaper than
355
+ a shared shape nobody else wants.
356
+
357
+ Deliberately NOT `schema_version`-guarded a second time: the caller has
358
+ already been through `_read_som_preset`/`read_sdk_som_and_soc` for this
359
+ same file, so a preset that fails that guard never reaches here. A
360
+ non-list `memory_map`, or entries that are not mappings, degrade to `[]`
361
+ /are skipped downstream -- an unreadable budget must fall back to
362
+ `mram_mb`, never raise out of `tan size`.
363
+ """
364
+ text = _read_text(path)
365
+ if text is None:
366
+ return []
367
+ try:
368
+ root = load_yaml_document(text)
369
+ except Exception: # noqa: BLE001 -- SystemManifestError or any PyYAML failure
370
+ return []
371
+ if not isinstance(root, dict):
372
+ return []
373
+ entries = root.get("memory_map")
374
+ if not isinstance(entries, list):
375
+ return []
376
+ return [e for e in entries if isinstance(e, dict)]
377
+
378
+
379
+ def _clean_str(value: Any) -> str | None:
380
+ """`str_clean`: a string, with the `TBD` sentinel dropped to absent.
381
+
382
+ Delegates to `tan.core.pending.is_pending_placeholder` (#276) for the
383
+ trimmed, non-case-folded, non-substring comparison shared with `tan
384
+ flash` -- not a second hand-rolled `== "TBD"`."""
385
+ if not isinstance(value, str) or is_pending_placeholder(value):
386
+ return None
387
+ return value
388
+
389
+
390
+ def _as_f64(value: Any) -> float | None:
391
+ """serde_json's `as_f64`: a JSON number, never a bool and never a string.
392
+
393
+ The `float()` is guarded (tan-cli#499 defect 8, REVIEW round). Python's
394
+ `json` reads an arbitrarily long integer literal exactly, so a 400-digit
395
+ `mram_mb` / `soc_flash_mb` / `tcm_kb` in a SoC JSON arrives here as an
396
+ `int` that `float()` cannot represent -- `OverflowError: int too large to
397
+ convert to float`, which escaped to `size`'s catch-all and collapsed the
398
+ WHOLE run: measured `exit 5 size.internal-failure`, `data.slices` empty
399
+ (every other slice's measurement discarded). The first pass at defect 8
400
+ fixed `core/size.py`'s three casts and left this one, so the same input
401
+ class still reached the same exit-5 point through a different door.
402
+
403
+ `None` -- "no such number" -- is the answer, which is what a value serde_json
404
+ could not have produced deserves; `resolve_budget` then leaves that region
405
+ unresolved instead of guessing. Measured after: the same SoC JSON is
406
+ `exit 0`, other slices intact. Still not byte-identical to the oracle, which
407
+ refuses the whole document and answers `budget_note: "unreadable SoM preset
408
+ for <SKU>"` -- that residual lives in `_read_soc`'s degrade-to-empty (and in
409
+ `json.loads` accepting literals serde_json rejects), not here.
410
+ """
411
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
412
+ return None
413
+ try:
414
+ return float(value)
415
+ except OverflowError:
416
+ return None
417
+
418
+
419
+ def _read_soc(
420
+ path: str, *, metadata_root: str | None = None, warnings: list[str] | None = None,
421
+ skipped: list[str] | None = None,
422
+ ) -> tuple[list[dict], float | None, list[tuple[str, float | None]]]:
423
+ """`(variants, soc_flash_mb, soc_cores)` from a SoC JSON, defaulting to
424
+ empty on any failure.
425
+
426
+ `variants` is all-or-nothing on purpose: the oracle deserializes the whole
427
+ list with `unwrap_or_default()`, so ONE malformed entry empties it. Reading
428
+ the good ones here would resolve a budget the shipped binary does not.
429
+
430
+ *metadata_root*/*warnings* (tan-cli#964): see `_read_som_preset` -- same
431
+ contract, `soc-spec-v1.schema.json` instead. This is what closes the
432
+ ORIGINAL tan-cli#957 crash class for `tan size`: `soc_cores` below already
433
+ guards `core.get("tcm_kb")` through `_as_f64` and `core_id` through
434
+ `isinstance(core_id, str)`, so a schema-invalid `cores[].type` (the
435
+ field THIS reader never even looks at) could previously resolve a budget
436
+ from a document nothing had ever validated, with no trace on the wire.
437
+ """
438
+ text = _read_text(path)
439
+ if text is None:
440
+ return [], None, []
441
+ try:
442
+ soc = json.loads(text)
443
+ except (ValueError, RecursionError):
444
+ return [], None, []
445
+ if not isinstance(soc, dict):
446
+ return [], None, []
447
+ if metadata_root is not None:
448
+ schema_path = soc_spec_schema_path(metadata_root)
449
+ if skipped is not None:
450
+ note = missing_schema_note(schema_path, source=path)
451
+ if note is not None:
452
+ skipped.append(note)
453
+ if warnings is not None:
454
+ warnings.extend(validate_document(soc, schema_path, path))
455
+
456
+ variants: list[dict] = []
457
+ raw_variants = soc.get("variants")
458
+ if raw_variants is not None:
459
+ variants = _coerce_variants(raw_variants)
460
+
461
+ soc_flash_mb = _as_f64(soc.get("soc_flash_mb"))
462
+
463
+ soc_cores: list[tuple[str, float | None]] = []
464
+ raw_cores = soc.get("cores")
465
+ if isinstance(raw_cores, list):
466
+ for core in raw_cores:
467
+ if not isinstance(core, dict):
468
+ continue
469
+ core_id = core.get("id")
470
+ if not isinstance(core_id, str):
471
+ continue # `filter_map` skips an entry with no string `id`
472
+ soc_cores.append((core_id, _as_f64(core.get("tcm_kb"))))
473
+ return variants, soc_flash_mb, soc_cores
474
+
475
+
476
+ def _coerce_variants(raw: Any) -> list[dict]:
477
+ if not isinstance(raw, list):
478
+ return []
479
+ out: list[dict] = []
480
+ for entry in raw:
481
+ if not isinstance(entry, dict):
482
+ return []
483
+ order_code = entry.get("order_code")
484
+ if order_code is not None and not isinstance(order_code, str):
485
+ return []
486
+ skus = entry.get("alp_module_skus", [])
487
+ if not isinstance(skus, list) or any(not isinstance(s, str) for s in skus):
488
+ return []
489
+ mram = entry.get("mram_mb")
490
+ if mram is not None and (isinstance(mram, bool) or not isinstance(mram, (int, float))):
491
+ return []
492
+ banks = entry.get("sram_banks_kb", {})
493
+ if not isinstance(banks, dict):
494
+ return []
495
+ out.append(entry)
496
+ return out
497
+
498
+
499
+ def _resolve_slice_budget(
500
+ sku: str | None, core_id: str, metadata_root: str | None,
501
+ *, warnings: list[str] | None = None, skipped: list[str] | None = None,
502
+ ) -> MemoryBudget:
503
+ """One core's `(flash_total, ram_total)` from the SoM preset + SoC JSON under
504
+ `<sdk>/metadata`. The pure FLASH/RAM/note logic is `core.size.resolve_budget`;
505
+ this only reads the files.
506
+
507
+ *warnings* (tan-cli#964) is threaded to `read_sdk_som_and_soc` unchanged;
508
+ see its own docstring. *skipped* (tan-cli#964 review, major 6) is the
509
+ same threading for the "skip-but-disclose" collector."""
510
+ if sku is None:
511
+ return budget_note_only("no SKU in manifest")
512
+ if metadata_root is None:
513
+ return budget_note_only(f"no SoM preset for {sku}")
514
+
515
+ preset_path = os.path.join(metadata_root, "e1m_modules", f"{sku}.yaml")
516
+ if not _is_file(preset_path):
517
+ return budget_note_only(f"no SoM preset for {sku}")
518
+ walked = read_sdk_som_and_soc(metadata_root, sku, warnings=warnings, skipped=skipped)
519
+ if walked is None:
520
+ return budget_note_only(f"unreadable SoM preset for {sku}")
521
+ _silicon, silicon_variant, variants, soc_flash_mb, soc_cores = walked
522
+
523
+ variant = resolve_variant(silicon_variant, sku, variants)
524
+ mram_mb = None if variant is None else _as_f64(variant.get("mram_mb"))
525
+ banks = [] if variant is None else sram_banks(variant)
526
+ # tan-cli#747: this core's OWN slot0 window, when the SoM declares one, is
527
+ # the only FLASH a core can link into -- `mram_mb` is the whole part,
528
+ # including the other core's slot0.
529
+ memory_map = _read_som_memory_map(preset_path)
530
+ return resolve_budget(
531
+ core_id, mram_mb, soc_flash_mb, banks, soc_cores, memory_map
532
+ )
533
+
534
+
535
+ def _measure_slice(
536
+ slice_: dict,
537
+ build_root: str,
538
+ sku: str | None,
539
+ metadata_root: str | None,
540
+ size_bin: str | None,
541
+ *,
542
+ warnings: list[str] | None = None,
543
+ skipped: list[str] | None = None,
544
+ ) -> SliceSize:
545
+ """Measure + budget one manifest slice into a `SliceSize` row.
546
+
547
+ *warnings*/*skipped* (tan-cli#964 / review major 6) are threaded to
548
+ `_resolve_slice_budget` unchanged."""
549
+ core_id = str(slice_.get("core_id"))
550
+ os_name = str(slice_.get("os"))
551
+
552
+ if os_name != "zephyr":
553
+ return SliceSize(
554
+ core_id=core_id,
555
+ os=os_name,
556
+ status="n/a",
557
+ note="no Zephyr image (Yocto/baremetal)",
558
+ )
559
+
560
+ budget = _resolve_slice_budget(
561
+ sku, core_id, metadata_root, warnings=warnings, skipped=skipped
562
+ )
563
+ sizes, source, probed_elf = _extract_sizes(
564
+ slice_elf_candidates(slice_, build_root),
565
+ slice_footprint_dirs(slice_, build_root),
566
+ size_bin,
567
+ budget.ram_off_budget_spans,
568
+ )
569
+
570
+ # tan-cli#499 defect 2: `size` never consulted the manifest `status`, so a
571
+ # slice recorded `failed`/`skipped` was still measured from whatever
572
+ # `zephyr.elf` was left on disk -- RUN 1's artefact -- and emitted as an
573
+ # ordinary measured row (`source: "size-tool"`, exit 0, `issues: []`).
574
+ # `overlay_run_results_raw` deliberately PRESERVES the previous
575
+ # `output_artefact` when a run carries none, and an empty one falls
576
+ # through to the preserved `build_dir` probe, so the compensation
577
+ # `build_cmd` applies on the WRITE side does not help. Worse under
578
+ # `--fail-over-budget`, which then computes its verdict from stale bytes.
579
+ # This is the same stale-artefact class `flash_plan.slice_should_flash`
580
+ # and `image_bundle.slice_should_bundle` both guard -- whose own docstring
581
+ # claims the predicate is "shared on purpose so `flash` and `image` can
582
+ # never disagree about which artefacts are real"; `size` consulted
583
+ # neither.
584
+ #
585
+ # Only a DECLARED, non-ok status refuses. An ABSENT `status` still
586
+ # measures, unchanged: a manifest that omits the key says nothing about
587
+ # whether the slice built, and refusing there would break the oracle
588
+ # parity every no-status case has today (the whole `test_field_type_
589
+ # leniency_parity` family, and `test_size_measured_slice_and_the_sdk_
590
+ # envelope_key`, all write slices with no `status`).
591
+ #
592
+ # And only when a measurement was actually SUPPRESSED. When the probe
593
+ # found nothing anyway the row is already `not-built` for the honest
594
+ # reason, and saying so in the oracle's own words keeps byte parity on
595
+ # every frozen case that carries a non-ok status with no artefact behind
596
+ # it -- so this divergence materialises exactly where the defect does.
597
+ declared_status = slice_.get("status")
598
+ stale = (
599
+ sizes is not None
600
+ and isinstance(declared_status, str)
601
+ and not slice_should_flash(declared_status)
602
+ )
603
+ if stale:
604
+ return SliceSize(
605
+ core_id=core_id,
606
+ os=os_name,
607
+ status="not-built",
608
+ flash_total=budget.flash_total,
609
+ ram_total=budget.ram_total,
610
+ note=budget.note,
611
+ notes=[
612
+ f"slice did not build (manifest status: {declared_status}); "
613
+ f"refusing to report a stale measurement from {probed_elf}"
614
+ ],
615
+ )
616
+
617
+ if sizes is None:
618
+ return SliceSize(
619
+ core_id=core_id,
620
+ os=os_name,
621
+ status="not-built",
622
+ flash_total=budget.flash_total,
623
+ ram_total=budget.ram_total,
624
+ note=budget.note,
625
+ notes=[f"no footprint source at {probed_elf}"],
626
+ )
627
+
628
+ flash_used, ram_used = sizes
629
+ if budget.flash_total is None and budget.ram_total is None:
630
+ status = "no-budget"
631
+ else:
632
+ status = classify(
633
+ flash_used, budget.flash_total, ram_used, budget.ram_total
634
+ )
635
+ return SliceSize(
636
+ core_id=core_id,
637
+ os=os_name,
638
+ status=status,
639
+ flash_used=flash_used,
640
+ flash_total=budget.flash_total,
641
+ ram_used=ram_used,
642
+ ram_total=budget.ram_total,
643
+ source=source,
644
+ note=budget.note,
645
+ )
646
+
647
+
648
+ def _use_color(no_color: bool, ci: bool) -> bool:
649
+ """Delegates to `tan.env.use_color` (tan-cli's UX-polish sweep moved the
650
+ logic there so `doctor_cmd` can reuse it without importing this command
651
+ module) -- kept as a same-named wrapper so this file's own call site and
652
+ `test_size_command.py` stay untouched."""
653
+ return use_color(no_color, ci)
654
+
655
+
656
+ def _join_core_ids(rows: list[SliceSize]) -> str:
657
+ return ", ".join(r.core_id for r in rows)
658
+
659
+
660
+ @dataclass
661
+ class _Outcome:
662
+ """What one run produced. Built and returned, never emitted in place, so the
663
+ exception guard in [`size`] can wrap the whole computation without also
664
+ catching `typer.Exit` (a RuntimeError subclass, not a SystemExit)."""
665
+
666
+ exit_code: ExitCode
667
+ data: dict[str, Any]
668
+ project: Project
669
+ issues: list[Issue]
670
+ text: list[str]
671
+ #: Absent, never `null`. Carried from the project resolution that produced
672
+ #: `project`, never a second lookup -- so it is present on the manifest-error
673
+ #: paths too (`Envelope::new` reads what `resolve_cli_project_context`
674
+ #: recorded, and that ran before the manifest was opened).
675
+ sdk: SdkInfo | None = None
676
+
677
+
678
+ def _sdk_warning_lines(issues: list[Issue]) -> list[str]:
679
+ """The text-mode rendering of every issue `_run` has collected by this
680
+ point -- originally just the `sdk_resolution_issues` pair (tan-cli#497
681
+ defect 5), now also `size.metadata-schema-invalid`/`-unchecked`
682
+ (tan-cli#964). `{severity}: {message}` -- the shape `tan build` and `tan
683
+ run` already print a resolution warning with, so the same workspace
684
+ reads the same way whichever command a developer runs.
685
+
686
+ tan-cli#964 review (minor 11): a schema-invalid issue's own `message`
687
+ (`<file>: <pointer>: <what jsonschema found>`) does not itself say
688
+ "schema", so this text line could not be told apart from any other
689
+ warning at a glance -- unlike the REFUSE path's "does not validate
690
+ against soc-spec-v1" wording. Tagged here, at the print site, rather
691
+ than folded into `message` itself, which is the same string the JSON
692
+ envelope carries and this PR's own tests pin verbatim.
693
+ """
694
+ return [
695
+ f"{issue.severity}: "
696
+ f"{'schema: ' if issue.code.endswith('.metadata-schema-invalid') else ''}"
697
+ f"{issue.message}"
698
+ for issue in issues
699
+ ]
700
+
701
+
702
+ def _error_outcome(
703
+ project: Project,
704
+ context: ProjectContext,
705
+ code: str,
706
+ message: str,
707
+ ) -> _Outcome:
708
+ """A hard manifest error: exit 1, an empty `alp-size/1` report as `data` so
709
+ the envelope shape stays stable, and the message prefixed for text mode.
710
+
711
+ tan-cli#464 review: this used to take a bare `sdk: SdkInfo | None` and
712
+ report only the `code`/`message` issue -- so a manifest gate that fires
713
+ before `_run`'s own `sdk_resolution_issues` call further down reported
714
+ `sdk.sourceTier: "globalDefault"` with neither
715
+ `sdk.project-pin-unresolved` nor `sdk.global-default-foreign-project`,
716
+ even when one applied. Takes the whole `context` instead of just its `sdk`
717
+ field so it can compute the same pair `_run` computes on the happy path,
718
+ from the one shared `sdk_resolution_issues` -- no future early return
719
+ through here can drop them again.
720
+
721
+ tan-cli#497 defect 5 (the `size` sibling the issue names alongside
722
+ `image`): that fix reached `issues` and stopped there -- `text` was built
723
+ without the pair, so the DEFAULT mode dropped both warnings while
724
+ `--format json` reported them. Composed from the SAME list now.
725
+ """
726
+ issues = sdk_resolution_issues(
727
+ context.broken_project_pin, context.sdk_source_tier, context.foreign_global_default_for
728
+ )
729
+ text = _sdk_warning_lines(issues)
730
+ issues.append(Issue(code, "error", message))
731
+ return _Outcome(
732
+ ExitCode.RUNTIME_FAILURE,
733
+ build_size_report([]),
734
+ project,
735
+ issues,
736
+ [*text, f"size: {message}"],
737
+ context.sdk,
738
+ )
739
+
740
+
741
+ def _run(
742
+ *,
743
+ app_path: str,
744
+ build_root_arg: str | None,
745
+ board: str | None,
746
+ fail_over_budget: bool,
747
+ project_arg: str | None,
748
+ board_yaml_arg: str | None,
749
+ sdk_root_arg: str | None,
750
+ json_mode: bool,
751
+ no_color: bool,
752
+ ci: bool,
753
+ disclosure: SdkDisclosure,
754
+ ) -> _Outcome:
755
+ """`disclosure` is the caller's, by reference -- the resolution facts are
756
+ computed HERE and `size`'s `size.internal-failure` catch-all needs a name
757
+ to read them from once this function has already raised. See
758
+ `SdkDisclosure`."""
759
+ context: ProjectContext = resolve_project_context(
760
+ project_arg, board_yaml_arg, sdk_root_arg
761
+ )
762
+ project = context.project()
763
+ # Recorded the instant the ladder answers, ahead of every other step this
764
+ # function performs -- all of which can raise something unenumerated.
765
+ disclosure.record(
766
+ context.sdk,
767
+ sdk_resolution_issues(
768
+ context.broken_project_pin,
769
+ context.sdk_source_tier,
770
+ context.foreign_global_default_for,
771
+ ),
772
+ )
773
+
774
+ app_base = resolve_app_base(app_path, context.workspace_root)
775
+ build_root = resolve_build_root(build_root_arg, app_base)
776
+ metadata_sdk = resolve_metadata_sdk_root(sdk_root_arg, context.workspace_root)
777
+ if metadata_sdk is None and (sdk_root_arg or "").strip():
778
+ # tan-cli#1463: an EXPLICIT `--sdk-root` that did not resolve used to
779
+ # continue silently with `metadata_sdk=None` and size the build with
780
+ # no SDK metadata. No flag + no checkout still continues (nothing was
781
+ # asked for). Intentional divergence from the v0.4.1 oracle, which
782
+ # reached `size.manifest-unavailable`; this refusal now precedes the
783
+ # manifest checks.
784
+ return _error_outcome(
785
+ project,
786
+ context,
787
+ "size.sdk-root-unresolved",
788
+ rejected_sdk_root_message(sdk_root_arg, "No size report was produced."),
789
+ )
790
+ metadata_root = (
791
+ None if metadata_sdk is None else os.path.join(str(metadata_sdk), "metadata")
792
+ )
793
+
794
+ build_root = effective_build_root(build_root)
795
+ try:
796
+ _text, manifest = load_manifest(build_root)
797
+ except ManifestUnavailable as err:
798
+ return _error_outcome(
799
+ project,
800
+ context,
801
+ "size.manifest-unavailable",
802
+ f"no system-manifest.yaml at {err.path}; run `tan build` first "
803
+ f"({err.detail}).",
804
+ )
805
+ except ManifestInvalid as err:
806
+ return _error_outcome(
807
+ project, context, "size.manifest-invalid", f"{err.path}: {err.detail}"
808
+ )
809
+
810
+ sku = board if board is not None else (manifest.sku.strip() or None)
811
+ # tan-cli#567: the RESOLVED absolute path of the first size tool that is
812
+ # really on PATH, not its bare name -- that path is what
813
+ # `_sizes_from_size_tool` spawns, so the current directory cannot supply a
814
+ # different `size.exe` than the one this check just approved. `None` still
815
+ # means "no size tool on PATH", and the fallback rungs below are unchanged.
816
+ size_bin = next(filter(None, (_find_on_path(t) for t in SIZE_TOOLS)), None)
817
+ # tan-cli#964: one collector shared across every slice -- several slices
818
+ # on a heterogeneous SoM resolve their budget off the SAME SoM
819
+ # preset/SoC JSON, and `dict.fromkeys` below dedupes the repeats rather
820
+ # than reporting one violation once per core.
821
+ schema_warnings: list[str] = []
822
+ schema_skipped: list[str] = []
823
+ rows = [
824
+ _measure_slice(
825
+ s, build_root, sku, metadata_root, size_bin,
826
+ warnings=schema_warnings, skipped=schema_skipped,
827
+ )
828
+ for s in manifest.slices
829
+ ]
830
+ schema_warnings = list(dict.fromkeys(schema_warnings))
831
+ schema_skipped = list(dict.fromkeys(schema_skipped))
832
+
833
+ text: list[str] = []
834
+ # The same pair `_error_outcome` above computes for a manifest-gate
835
+ # refusal, from the same shared `sdk_resolution_issues` -- read back off
836
+ # the disclosure rather than computed a second time, so this path, that one
837
+ # and the catch-all can never disagree about whether either warning applies.
838
+ issues: list[Issue] = list(disclosure.issues)
839
+ # tan-cli#964: warn-and-continue, never refuse -- `tan size` already
840
+ # degrades a bad SoM preset/SoC JSON to `budget: unknown` (see
841
+ # `_resolve_slice_budget`'s own docstring); this only makes WHY visible.
842
+ issues.extend(
843
+ Issue("size.metadata-schema-invalid", "warning", w) for w in schema_warnings
844
+ )
845
+ # tan-cli#964 review (major 6, "skip-but-disclose"): `info`, not
846
+ # `warning` -- nothing here says a document is wrong, only that a
847
+ # schema file to check it against is absent from this checkout.
848
+ issues.extend(
849
+ Issue("size.metadata-schema-unchecked", "info", w) for w in schema_skipped
850
+ )
851
+ version_issue = _som_schema_version_issue(metadata_root, sku)
852
+ if version_issue is not None:
853
+ issues.append(version_issue)
854
+ exit_code = ExitCode.SUCCESS
855
+
856
+ if not json_mode:
857
+ # tan-cli#497 defect 5, happy-path half: the pair above went into
858
+ # `issues` and nowhere else, so a measured report solved out of a
859
+ # FALLBACK checkout printed its table with no mention that the
860
+ # project pin had been ignored. Ahead of the table so it is not lost
861
+ # below a long slice list.
862
+ text.extend(_sdk_warning_lines(issues))
863
+ text.extend(render_table_lines(rows, _use_color(no_color, ci)))
864
+ if size_bin is None:
865
+ text.append(
866
+ "size: no size tool on PATH (arm-zephyr-eabi-size / llvm-size / "
867
+ "size); measured from ELF section headers (or rom.json+ram.json)."
868
+ )
869
+
870
+ if fail_over_budget:
871
+ unknown = unknown_budget_rows(rows)
872
+ if unknown:
873
+ message = (
874
+ f"size: budget unknown for [{_join_core_ids(unknown)}] "
875
+ "— skipped by --fail-over-budget (no guess)."
876
+ )
877
+ if not json_mode:
878
+ text.append(message)
879
+ issues.append(Issue("size.budget-unknown", "info", message))
880
+ over = over_budget_rows(rows)
881
+ if over:
882
+ message = f"size: over budget: [{_join_core_ids(over)}]."
883
+ if not json_mode:
884
+ text.append(message)
885
+ issues.append(Issue("size.over-budget", "error", message))
886
+ exit_code = ExitCode.RUNTIME_FAILURE
887
+
888
+ return _Outcome(
889
+ exit_code, build_size_report(rows), project, issues, text, context.sdk
890
+ )
891
+
892
+
893
+ def size(
894
+ ctx: typer.Context,
895
+ app_path: str = typer.Argument(
896
+ ".",
897
+ metavar="APP_PATH",
898
+ help="Application source directory (default: '.'). build_root defaults "
899
+ "to <APP_PATH>/build. Overrides the global --project when not '.'.",
900
+ ),
901
+ build_root: str = typer.Option(
902
+ None,
903
+ "--build-root",
904
+ metavar="PATH",
905
+ help="Override the build root holding system-manifest.yaml "
906
+ "(default: <APP_PATH>/build).",
907
+ ),
908
+ board: str = typer.Option(
909
+ None,
910
+ "--board",
911
+ metavar="SKU",
912
+ help="Override the SoM SKU used to resolve the memory budget (default: "
913
+ "hw_info.sku from the manifest). Distinct from --board-yaml.",
914
+ ),
915
+ fail_over_budget: bool = typer.Option(
916
+ False,
917
+ "--fail-over-budget",
918
+ help="Exit non-zero if any slice exceeds its resolved budget (slices "
919
+ "with an unknown budget are skipped + reported, never guessed).",
920
+ ),
921
+ project: str = typer.Option(
922
+ None, "--project", metavar="PATH", help="Project root (defaults to '.')."
923
+ ),
924
+ board_yaml: str = typer.Option(
925
+ None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
926
+ ),
927
+ sdk_root: str = typer.Option(
928
+ None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
929
+ ),
930
+ output_format: OutputFormat = typer.Option(None, "--format", help=FORMAT_HELP),
931
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colour."),
932
+ ci: bool = typer.Option(False, "--ci", help="Non-interactive CI mode."),
933
+ ) -> None:
934
+ """Report per-slice firmware footprint vs the SoM memory budget."""
935
+ resolved_format = resolve_format(output_format, ctx.obj, choices=OutputFormat)
936
+ json_mode = resolved_format == "json"
937
+
938
+ # tan-cli#497 defect 5, the site the first pass missed -- see the twin
939
+ # comment in `image_cmd.image`. `_error_outcome` and the happy path both
940
+ # report the SDK-resolution pair; this handler, which runs strictly after
941
+ # `resolve_project_context` has already answered, reported only the crash.
942
+ # Recorded rather than recomputed here: the resolver is itself one of the
943
+ # things that can raise, and this handler must not.
944
+ disclosure = SdkDisclosure()
945
+ try:
946
+ outcome = _run(
947
+ app_path=app_path,
948
+ build_root_arg=build_root,
949
+ board=board,
950
+ fail_over_budget=fail_over_budget,
951
+ project_arg=project,
952
+ board_yaml_arg=board_yaml,
953
+ sdk_root_arg=sdk_root,
954
+ json_mode=json_mode,
955
+ no_color=no_color,
956
+ ci=ci,
957
+ disclosure=disclosure,
958
+ )
959
+ except Exception as err: # noqa: BLE001
960
+ # The port's most-repeated defect class: an uncaught exception escapes as
961
+ # a raw traceback, stdout stays EMPTY, and the extension renders nothing
962
+ # at all with no error on either side. Anything reaching here is a tan
963
+ # bug and is reported as one -- with an envelope. Nothing in this handler
964
+ # may itself throw: it only formats `err`, `_sdk_warning_lines` only
965
+ # formats, and `Project(None, None)` and `build_size_report([])` are
966
+ # both total.
967
+ outcome = _Outcome(
968
+ ExitCode.INTERNAL_FAILURE,
969
+ build_size_report([]),
970
+ Project(root=None, board_yaml=None),
971
+ [
972
+ *disclosure.issues,
973
+ Issue(
974
+ "size.internal-failure",
975
+ "error",
976
+ f"size failed unexpectedly: {type(err).__name__}: {err}",
977
+ ),
978
+ ],
979
+ [*_sdk_warning_lines(disclosure.issues), "size: internal failure"],
980
+ disclosure.sdk,
981
+ )
982
+
983
+ # Built ONCE, for both formats: `Envelope.__init__` appends the tan-cli#407
984
+ # `sdk.discovery-divergent` warning at the shared seam (`_with_sdk_
985
+ # divergence`), and `outcome.text` was assembled strictly before any
986
+ # `Envelope` existed -- so a seam-appended issue reached `--format json`
987
+ # and was silent on the default text channel (tan-cli#799). Diffed
988
+ # against `outcome.issues` (by value: `Issue` is a frozen dataclass) so
989
+ # only what the seam ADDED is rendered, never a duplicate of a warning
990
+ # `outcome.text` already carries (the pair `_run`/`_error_outcome` render
991
+ # via `_sdk_warning_lines` above).
992
+ envelope = Envelope(
993
+ "size",
994
+ outcome.project,
995
+ outcome.data,
996
+ outcome.issues,
997
+ outcome.exit_code,
998
+ sdk=outcome.sdk,
999
+ )
1000
+ if json_mode:
1001
+ emit(envelope)
1002
+ else:
1003
+ seam_extra = [issue for issue in envelope.issues if issue not in outcome.issues]
1004
+ # stdout is the envelope channel and carries nothing else, in either
1005
+ # mode; stderr carries no contract of its own.
1006
+ stream = typer.get_text_stream("stderr")
1007
+ for issue in seam_extra:
1008
+ stream.write(f"{issue.severity}: {issue.message}\n")
1009
+ for line in outcome.text:
1010
+ stream.write(f"{line}\n")
1011
+ raise typer.Exit(int(outcome.exit_code))
1012
+
1013
+
1014
+ # tan-cli#261: adds the five oracle `GlobalArgs` flags this command was still
1015
+ # missing (`--all`/`--non-interactive`/`--quiet`/`--target`/`--verbose`) on
1016
+ # top of `--no-color`/`--ci`, already declared and read above (`_use_color`);
1017
+ # see `tan.core.global_flags`. `ctx: typer.Context` (this command's own
1018
+ # `_HONOURS_ROOT_FORMAT` seam) is untouched -- appended parameters are all
1019
+ # keyword-only Options, never repositioned relative to it.
1020
+ size = accept_global_flags(size)