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.
- tan/__init__.py +0 -0
- tan/__main__.py +120 -0
- tan/cli.py +939 -0
- tan/command_table.py +265 -0
- tan/commands/__init__.py +8 -0
- tan/commands/bootstrap_cmd.py +4856 -0
- tan/commands/bootstrap_patches.py +164 -0
- tan/commands/bootstrap_toolchain_fetch.py +155 -0
- tan/commands/bootstrap_toolchain_pin.py +36 -0
- tan/commands/build/__init__.py +1 -0
- tan/commands/build/configure_inputs.py +277 -0
- tan/commands/build/execute.py +1852 -0
- tan/commands/build/host_python.py +59 -0
- tan/commands/build/link_stale.py +78 -0
- tan/commands/build/manifest.py +428 -0
- tan/commands/build/materialise.py +81 -0
- tan/commands/build/token_substitution.py +381 -0
- tan/commands/build/toolchain.py +482 -0
- tan/commands/build/user_defines_wipe.py +115 -0
- tan/commands/build/workspace_patches.py +65 -0
- tan/commands/build_cmd.py +2236 -0
- tan/commands/build_output.py +331 -0
- tan/commands/clean_cmd.py +1183 -0
- tan/commands/completion_cmd.py +786 -0
- tan/commands/debug_config_cmd.py +2425 -0
- tan/commands/diff_cmd.py +940 -0
- tan/commands/doctor_cmd.py +4767 -0
- tan/commands/examples_cmd.py +644 -0
- tan/commands/explain_cmd.py +1319 -0
- tan/commands/faultdecode_cmd.py +931 -0
- tan/commands/flash_atoc_guard.py +240 -0
- tan/commands/flash_cmd.py +5731 -0
- tan/commands/flash_mram_guard.py +150 -0
- tan/commands/flash_ram.py +578 -0
- tan/commands/flash_raw.py +583 -0
- tan/commands/generate_cmd.py +1456 -0
- tan/commands/image_cmd.py +753 -0
- tan/commands/init_cmd.py +1740 -0
- tan/commands/inspect_cmd.py +377 -0
- tan/commands/kconfig_cmd.py +734 -0
- tan/commands/model_cmd.py +1514 -0
- tan/commands/model_device_cmd.py +351 -0
- tan/commands/model_device_live.py +234 -0
- tan/commands/model_host_cmd.py +430 -0
- tan/commands/model_zoo_cmd.py +363 -0
- tan/commands/monitor_actions.py +125 -0
- tan/commands/monitor_cmd.py +626 -0
- tan/commands/monitor_session.py +505 -0
- tan/commands/new_som_cmd.py +1522 -0
- tan/commands/pinmux_cmd.py +568 -0
- tan/commands/presets_cmd.py +1123 -0
- tan/commands/probe_cmd.py +421 -0
- tan/commands/reset_cmd.py +354 -0
- tan/commands/reset_confirm.py +168 -0
- tan/commands/run_cmd.py +637 -0
- tan/commands/scaffold_cmd.py +490 -0
- tan/commands/sdk_cmd.py +1723 -0
- tan/commands/size_cmd.py +1020 -0
- tan/commands/support_bundle_cmd.py +1073 -0
- tan/commands/trace_cmd.py +341 -0
- tan/commands/validate_cmd.py +2009 -0
- tan/commands/west_forward_cmd.py +736 -0
- tan/commands/workspace_patch_check.py +296 -0
- tan/core/__init__.py +1 -0
- tan/core/alp_conf_pregen.py +100 -0
- tan/core/artifact_provenance.py +240 -0
- tan/core/atoc_guard.py +276 -0
- tan/core/atoc_guard_messages.py +232 -0
- tan/core/atoc_replacement.py +118 -0
- tan/core/atomic_write.py +178 -0
- tan/core/board_context.py +351 -0
- tan/core/board_diagnostic.py +111 -0
- tan/core/board_files.py +221 -0
- tan/core/board_validator.py +542 -0
- tan/core/board_validator_compat.py +196 -0
- tan/core/board_validator_run.py +129 -0
- tan/core/board_validator_schema.py +186 -0
- tan/core/board_validator_skew.py +46 -0
- tan/core/board_yaml_edit.py +295 -0
- tan/core/board_yaml_pos.py +118 -0
- tan/core/bootstrap.py +2572 -0
- tan/core/build_plan.py +625 -0
- tan/core/consent.py +104 -0
- tan/core/console_filter.py +245 -0
- tan/core/debug_launch.py +1803 -0
- tan/core/devicetree_lint.py +168 -0
- tan/core/dir_removal.py +229 -0
- tan/core/doctor_git.py +172 -0
- tan/core/doctor_libraries.py +253 -0
- tan/core/doctor_render.py +185 -0
- tan/core/doctor_scope.py +66 -0
- tan/core/doctor_setools.py +237 -0
- tan/core/doctor_stale.py +180 -0
- tan/core/document_guards.py +723 -0
- tan/core/dp_id.py +97 -0
- tan/core/elf_load.py +63 -0
- tan/core/error_catalog.py +171 -0
- tan/core/example_catalog.py +499 -0
- tan/core/example_facets.py +151 -0
- tan/core/faultdecode.py +521 -0
- tan/core/flash_plan.py +2501 -0
- tan/core/flow_d_report.py +381 -0
- tan/core/flow_d_uptime.py +164 -0
- tan/core/fs_confine.py +130 -0
- tan/core/global_flags.py +230 -0
- tan/core/host_python.py +210 -0
- tan/core/image_bundle.py +247 -0
- tan/core/inert.py +147 -0
- tan/core/jlink_binary.py +128 -0
- tan/core/jlink_probe.py +481 -0
- tan/core/jsonc_splice.py +404 -0
- tan/core/launch_provenance.py +257 -0
- tan/core/link_refusal.py +76 -0
- tan/core/metadata_schema.py +376 -0
- tan/core/model_check.py +227 -0
- tan/core/model_device.py +449 -0
- tan/core/model_doctor.py +198 -0
- tan/core/model_host.py +48 -0
- tan/core/model_list.py +112 -0
- tan/core/model_zoo.py +353 -0
- tan/core/module_template.py +340 -0
- tan/core/mram_link.py +87 -0
- tan/core/new_som_ospi.py +94 -0
- tan/core/os_class.py +159 -0
- tan/core/patch_paths.py +87 -0
- tan/core/pending.py +40 -0
- tan/core/plain_zephyr_plan.py +138 -0
- tan/core/plan_exec.py +373 -0
- tan/core/plan_tokens.py +600 -0
- tan/core/probe.py +155 -0
- tan/core/probe_plan.py +237 -0
- tan/core/proxy.py +168 -0
- tan/core/publish.py +31 -0
- tan/core/python_floor.py +178 -0
- tan/core/ram_run.py +694 -0
- tan/core/ram_watch.py +231 -0
- tan/core/raw_write.py +160 -0
- tan/core/reset_plan.py +56 -0
- tan/core/run.py +161 -0
- tan/core/scaffold.py +2203 -0
- tan/core/scaffold_selftest_identity.py +225 -0
- tan/core/sdk_default_registry.py +565 -0
- tan/core/sdk_discovery.py +996 -0
- tan/core/sdk_removal.py +465 -0
- tan/core/serial_actions.py +175 -0
- tan/core/serial_capture.py +185 -0
- tan/core/serial_url.py +45 -0
- tan/core/setools.py +608 -0
- tan/core/setools_scratch.py +243 -0
- tan/core/shapes.py +194 -0
- tan/core/size.py +610 -0
- tan/core/som_buildability.py +204 -0
- tan/core/som_schema_version.py +62 -0
- tan/core/subprocess_env.py +201 -0
- tan/core/sysbuild_image.py +94 -0
- tan/core/system_manifest.py +827 -0
- tan/core/text_layout.py +73 -0
- tan/core/timestamp.py +182 -0
- tan/core/tool_lookup.py +383 -0
- tan/core/toolchain_pin.py +117 -0
- tan/core/toolchain_provision.py +1224 -0
- tan/core/trusted_path.py +134 -0
- tan/core/uboot_breakin.py +154 -0
- tan/core/uri_reference.py +415 -0
- tan/core/user_defines.py +152 -0
- tan/core/venv.py +332 -0
- tan/core/west_patches.py +143 -0
- tan/core/west_workspace_refusal.py +159 -0
- tan/core/zephyr_env.py +124 -0
- tan/env.py +185 -0
- tan/envelope.py +647 -0
- tan/exit_codes.py +12 -0
- tan/lazy_typer.py +185 -0
- tan/model/__init__.py +2 -0
- tan/model/_gen_fixture.py +132 -0
- tan/model/adapters/__init__.py +116 -0
- tan/model/adapters/cpu.py +26 -0
- tan/model/adapters/deepx.py +94 -0
- tan/model/adapters/drpai.py +242 -0
- tan/model/adapters/ethos_u.py +951 -0
- tan/model/adapters/executorch.py +55 -0
- tan/model/analyze.py +694 -0
- tan/model/build.py +304 -0
- tan/model/check.py +682 -0
- tan/model/manifest.py +625 -0
- tan/model/measure.py +216 -0
- tan/model/npu_ops_validate.py +130 -0
- tan/model/package.py +125 -0
- tan/model/perf.py +635 -0
- tan/model/perf_apply.py +721 -0
- tan/model/prep.py +193 -0
- tan/model/sram_fit.py +502 -0
- tan/model/targets.py +374 -0
- tan/model/tensorio.py +287 -0
- tan/net.py +73 -0
- tan/output_format.py +213 -0
- tan/planner/__init__.py +219 -0
- tan/planner/aperture.py +202 -0
- tan/planner/buildplan.py +1092 -0
- tan/planner/camera_owner.py +192 -0
- tan/planner/cameras.py +132 -0
- tan/planner/carveout.py +755 -0
- tan/planner/cli.py +274 -0
- tan/planner/headers.py +315 -0
- tan/planner/kconfig.py +2435 -0
- tan/planner/kconfig_symbols.py +473 -0
- tan/planner/libraries.py +677 -0
- tan/planner/link_target.py +237 -0
- tan/planner/linux_ownership.py +216 -0
- tan/planner/loader.py +1525 -0
- tan/planner/manifest.py +116 -0
- tan/planner/memory.py +161 -0
- tan/planner/memregion.py +56 -0
- tan/planner/models.py +533 -0
- tan/planner/orchestrator.py +874 -0
- tan/planner/ownership.py +203 -0
- tan/planner/partition.py +1160 -0
- tan/planner/paths.py +32 -0
- tan/planner/plain_slice.py +139 -0
- tan/planner/project_emit/__init__.py +60 -0
- tan/planner/project_emit/bom_netlist.py +375 -0
- tan/planner/project_emit/dts.py +580 -0
- tan/planner/project_emit/hw_info.py +221 -0
- tan/planner/project_emit/native_sim.py +136 -0
- tan/planner/project_emit/west_libs.py +213 -0
- tan/planner/project_loader.py +264 -0
- tan/planner/sdk_capability.py +203 -0
- tan/planner/sdk_compat.py +395 -0
- tan/planner/secure.py +350 -0
- tan/planner/slugs.py +394 -0
- tan/planner/som_metadata.py +325 -0
- tan/planner/strict_loaders.py +131 -0
- tan/planner/template.py +1037 -0
- tan/planner/template_pins.py +535 -0
- tan/planner/template_rewrite.py +714 -0
- tan/planner/topology.py +208 -0
- tan/planner/validate.py +365 -0
- tan/planner/whole_device_alias.py +67 -0
- tan/planner/zephyr_board.py +3510 -0
- tan/planner_cli.py +97 -0
- tan/planner_emit.py +617 -0
- tan/planner_root.py +119 -0
- tan/soc_ref.py +58 -0
- tan/templates/__init__.py +34 -0
- tan/templates/vendored/MANIFEST.md +1862 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
- tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
- tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
- tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
- tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
- tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
- tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
- tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
- tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
- tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
- tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
- tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
- tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
- tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
- tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
- tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
- tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
- tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
- tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
- tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
- tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
- tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
- tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
- tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
- tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
- tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
- tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
- tan/version.py +102 -0
- tan_cli-0.7.0.dist-info/METADATA +125 -0
- tan_cli-0.7.0.dist-info/RECORD +330 -0
- tan_cli-0.7.0.dist-info/WHEEL +5 -0
- tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
- tan_cli-0.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,4767 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""`tan doctor` -- is this host actually able to build and flash?
|
|
3
|
+
|
|
4
|
+
Every check here answers a question some customer already lost an afternoon to.
|
|
5
|
+
Two of them exist because the answer used to be a confident, wrong "Pass".
|
|
6
|
+
|
|
7
|
+
**The Python floor is not what the manifest says it is.**
|
|
8
|
+
`metadata/bootstrap.json` declares `prerequisites.pythonMinVersion` (read live
|
|
9
|
+
below, currently `"3.10"` on alp-sdk's `dev`), while separately
|
|
10
|
+
Zephyr's `cmake/modules/python.cmake` sets `PYTHON_MINIMUM_REQUIRED 3.12`. And
|
|
11
|
+
the Rust oracle's POSIX bootstrap branch was explicit that it "cannot fail on
|
|
12
|
+
version" (`crates/tan-cli/src/commands/bootstrap/steps.rs:230-234`). Ubuntu 22.04
|
|
13
|
+
ships `python3` = 3.10. Compose the three and a fresh customer got: `tan
|
|
14
|
+
bootstrap` succeeds, `tan doctor` reports Pass, and the FIRST build dies inside
|
|
15
|
+
Zephyr's CMake configure with an error naming Zephyr, not us. So the floor this
|
|
16
|
+
command enforces is the EFFECTIVE one -- the higher of the manifest's and
|
|
17
|
+
Zephyr's -- and where the two disagree that disagreement is itself reported
|
|
18
|
+
(`pythonFloor`), naming which is which, so the fix lands in the manifest instead
|
|
19
|
+
of in the customer.
|
|
20
|
+
|
|
21
|
+
**"Zephyr's" used to mean whatever `$ZEPHYR_BASE` pointed at, not the
|
|
22
|
+
workspace the report was actually about (tan-cli#301).** `zephyrWorkspace`
|
|
23
|
+
(tan-cli#290) reads the RESOLVED west topdir (`west_workspace_dir`); until now
|
|
24
|
+
`hostPython`/`pythonFloor` independently re-read `$ZEPHYR_BASE`, which is
|
|
25
|
+
extremely commonly stale -- Zephyr's own docs, and this command's own
|
|
26
|
+
`tan bootstrap` next-steps block, both tell a customer to export it. One
|
|
27
|
+
report could then name two different Zephyrs: `zephyrWorkspace` passing
|
|
28
|
+
against the real workspace while `hostPython`'s floor, and the interpreter it
|
|
29
|
+
demanded, came from an unrelated tree the customer was not building against.
|
|
30
|
+
`_collect` now feeds `zephyr_python_floor` the SAME resolved `workspace_path`
|
|
31
|
+
`zephyrWorkspace` reports, falling back to a literal `$ZEPHYR_BASE` read only
|
|
32
|
+
when no workspace resolves at all, and to `ZEPHYR_PYTHON_FLOOR` when neither
|
|
33
|
+
does -- see `zephyr_python_floor`'s docstring for the three-way split.
|
|
34
|
+
|
|
35
|
+
`tan bootstrap` now enforces the same effective floor on BOTH platforms, by
|
|
36
|
+
calling `zephyr_python_floor` below rather than re-deriving it -- see
|
|
37
|
+
`tan.commands.bootstrap_cmd.resolve_python_floor`. Keep that the ONE reader: a
|
|
38
|
+
second floor rule is how the two commands come to disagree about the same host,
|
|
39
|
+
which is worse than either verdict alone.
|
|
40
|
+
|
|
41
|
+
**SETOOLS was never mentioned by any doctor.** Neither `alp doctor`
|
|
42
|
+
(`scripts/alp_cli/doctor.py` -- it has `_check_python`, `_check_west`,
|
|
43
|
+
`_check_jlink`, and nothing for this) nor the shipped `tan doctor` says a word
|
|
44
|
+
about `SETOOLS_DIR` or `SE_UART`. A customer therefore gets a clean bill of
|
|
45
|
+
health and then meets a bare `RuntimeError` out of
|
|
46
|
+
`scripts/west_commands/runners/alif_flash.py` at the moment they try to flash an
|
|
47
|
+
AEN part. The `setools` check names both, plus the Alif developer download
|
|
48
|
+
(`app-release-exec-linux-SE_FW_x.y.z`) it cannot redistribute.
|
|
49
|
+
|
|
50
|
+
**This check used to ALSO gate on `fdt` being importable under some Python
|
|
51
|
+
interpreter, and that was a false negative even after tan-cli#488 defect 6
|
|
52
|
+
picked the "right" interpreter to ask (tan-cli#641).** Measured on real AEN
|
|
53
|
+
silicon, on a host where Flow D had just succeeded three times in the same
|
|
54
|
+
session: `fdt` genuinely was not importable in the workspace venv, yet
|
|
55
|
+
`app-gen-toc` ran fine, produced
|
|
56
|
+
`app-package-map.txt`, and the MRAM write it fed survived a cold power cycle.
|
|
57
|
+
Whatever `alif_flash.py`'s own `fdt` need is for the SE-UART `west flash`
|
|
58
|
+
path, this check's own wording bundled it into one verdict alongside
|
|
59
|
+
`app-gen-toc` -- which `tan.core.setools.sign_slot0` spawns as its own
|
|
60
|
+
subprocess (`./app-gen-toc -f build/config/...`), never as a Python import
|
|
61
|
+
under any interpreter this command could probe -- and SETOOLS ships whatever
|
|
62
|
+
dependencies IT needs regardless. Dropped rather than reworded again: probing
|
|
63
|
+
one interpreter's `sys.path` was never going to answer "will a flash that
|
|
64
|
+
spawns a separate, license-gated binary succeed," and the false alarm this
|
|
65
|
+
produced trains an operator to stop trusting doctor on the one question they
|
|
66
|
+
ask it before a write.
|
|
67
|
+
|
|
68
|
+
**Nothing that probes may throw.** Four Criticals in this port were uncaught
|
|
69
|
+
exceptions escaping the error contract: a raw traceback instead of an envelope,
|
|
70
|
+
so the VS Code extension renders nothing at all and neither side reports an
|
|
71
|
+
error. `doctor` interrogates a hostile environment BY DEFINITION -- a missing
|
|
72
|
+
binary, an unreadable directory, a tool that waits for a probe that is not
|
|
73
|
+
plugged in, a subprocess that answers in bytes that are not UTF-8. Every one of
|
|
74
|
+
those becomes a structured issue here; `probe()` is the single choke point and
|
|
75
|
+
it has a timeout on every call.
|
|
76
|
+
|
|
77
|
+
**Exit 4, never 0, when unhealthy.** A doctor that exits 0 on a broken
|
|
78
|
+
environment is worse than no doctor: it converts a fixable setup problem into a
|
|
79
|
+
mystery inside somebody else's build system.
|
|
80
|
+
|
|
81
|
+
Deliberately NOT ported from `crates/tan-cli/src/commands/doctor.rs`: the debug
|
|
82
|
+
half (`--target-kind`/`--server`, the cortex-debug/CodeLLDB extension set).
|
|
83
|
+
That needs context this port has no command to produce yet, and half a debug
|
|
84
|
+
verdict is worse than none. The envelope keys that survive --
|
|
85
|
+
`data.summary.{pass,warn,fail}` and `data.checks[]` -- are the ones
|
|
86
|
+
`alp-sdk-vscode` actually reads (`src/debug.ts`, `src/toolchain.ts`).
|
|
87
|
+
|
|
88
|
+
**`--build` is accepted, real, and now (tan-cli#290) a no-op vs. plain `tan
|
|
89
|
+
doctor` -- not the Rust oracle's second, disjoint check vocabulary.**
|
|
90
|
+
Measured against a real `tan.exe`, plain `tan doctor` and `tan doctor
|
|
91
|
+
--build` run two almost entirely different check lists (debug-readiness vs.
|
|
92
|
+
zephyr/yocto/baremetal build-readiness -- compare `tan doctor`'s
|
|
93
|
+
`workspaceRoot`/`codeLLDBExtension`/`lldb` against `tan doctor --build`'s
|
|
94
|
+
`git`/`cmake`/`ninja`/`dtc`/`gperf`/`vendorToolchain`/...). Byte-parity with
|
|
95
|
+
BOTH of those lists is a second command's worth of new checks, not a flag
|
|
96
|
+
gap -- and this port's own check list -- `hostPython`/`hostPrerequisites`/
|
|
97
|
+
`west`/`zephyrSdk`/`setools`/`jlink`, plus (tan-cli#294) `sdk`/`boardYaml`/
|
|
98
|
+
`workspace`/`zephyrVersion`/`zephyrSdkAvailableForHost`/`longPaths`/
|
|
99
|
+
`homePath`/`sdkProvenance`, plus (tan-cli#290) `westResolved`/
|
|
100
|
+
`zephyrWorkspace` -- is ALREADY build/flash-oriented by design (see above),
|
|
101
|
+
unlike the Rust oracle's PLAIN `doctor`. `zephyrWorkspace` -- whether the
|
|
102
|
+
RESOLVED workspace's Zephyr matches alp-sdk's `west.yml` pin -- used to be
|
|
103
|
+
the ONE check this flag gated; ADR 0021 Lane 1 P0a runs PLAIN `tan doctor`
|
|
104
|
+
as the very first command a customer types, before `--build` is ever named,
|
|
105
|
+
so gating it there left the exact alp-sdk#855 v4.4.0->v4.4.1 drift invisible
|
|
106
|
+
on that first run. It is unconditional now, alongside every other
|
|
107
|
+
tan-cli#294/#290 fact -- `--build` therefore changes nothing about this
|
|
108
|
+
port's check-name set any more. The flag stays accepted rather than
|
|
109
|
+
removed: both `alp-sdk-vscode` call sites (`["doctor", "--build"]`,
|
|
110
|
+
`["doctor", "--build", "--fix"]`) still pass it, and a flag a caller already
|
|
111
|
+
relies on does not need to keep doing something to still be worth accepting
|
|
112
|
+
without error.
|
|
113
|
+
|
|
114
|
+
**One name out of that unported `--build` vocabulary came BACK, deliberately
|
|
115
|
+
and partially (tan-cli#1192): `dtc`, and only as `devicetreeLint`.** The
|
|
116
|
+
sentence above still holds for `git`/`cmake`/`ninja`/`gperf`/`vendorToolchain`
|
|
117
|
+
-- none of those is ported and none is planned -- but `dtc`'s premise changed
|
|
118
|
+
underneath it. When this paragraph was written the Zephyr SDK put a `dtc` in
|
|
119
|
+
CMake's reach on every install, so probing for one could only restate what
|
|
120
|
+
`zephyrSdk` already said. Since tan-cli#1176/#1178 `tan bootstrap` acquires
|
|
121
|
+
the SDK with `--no-hosttools`, the SDK ships no `dtc`, and Zephyr's
|
|
122
|
+
devicetree LINT pass (`dts.cmake`'s `if(DTC)` block) silently stopped running
|
|
123
|
+
with nothing on either side saying so. `devicetreeLint` is not the oracle's
|
|
124
|
+
`dtc` check re-ported -- that one was a bare is-it-on-PATH presence probe,
|
|
125
|
+
which after #1178 would warn on every correct install -- it is the narrower
|
|
126
|
+
question that probe cannot answer; see `devicetree_lint_check` for the
|
|
127
|
+
predicate and the severity argument.
|
|
128
|
+
|
|
129
|
+
`--fix` is a separate, NOT-yet-ported flag gap (it is not part of this one):
|
|
130
|
+
the oracle's `--build --fix` auto-repairs a missing Zephyr workspace by
|
|
131
|
+
running `tan bootstrap`, and nothing here does that yet.
|
|
132
|
+
"""
|
|
133
|
+
import json
|
|
134
|
+
import os
|
|
135
|
+
import platform
|
|
136
|
+
import re
|
|
137
|
+
import shlex
|
|
138
|
+
import subprocess
|
|
139
|
+
import sys
|
|
140
|
+
import tempfile
|
|
141
|
+
from collections.abc import Callable, Iterable
|
|
142
|
+
from dataclasses import dataclass, field
|
|
143
|
+
from pathlib import Path
|
|
144
|
+
|
|
145
|
+
import typer
|
|
146
|
+
|
|
147
|
+
from tan.commands.build.toolchain import _is_toolchain_wreckage, _toolchain_store_scan_root
|
|
148
|
+
from tan.commands.flash_cmd import _tool_available
|
|
149
|
+
from tan.commands.sdk_cmd import (
|
|
150
|
+
NO_SDK_NEXT_STEPS,
|
|
151
|
+
global_default_pointer_fix_hint,
|
|
152
|
+
parse_sdk_version_yaml,
|
|
153
|
+
)
|
|
154
|
+
from tan.core import artifact_provenance
|
|
155
|
+
from tan.core.artifact_provenance import ArtifactProvenance
|
|
156
|
+
from tan.core.bootstrap import (
|
|
157
|
+
BOOTSTRAP_MANIFEST_SCHEMA_VERSION,
|
|
158
|
+
MissingPrerequisite,
|
|
159
|
+
PrereqFailure,
|
|
160
|
+
WorkspaceSdkRecord,
|
|
161
|
+
confirm_missing,
|
|
162
|
+
detect_linux_pm,
|
|
163
|
+
normalize_linux_install,
|
|
164
|
+
parse_west_zephyr_pin,
|
|
165
|
+
parse_workspace_sdk_record,
|
|
166
|
+
parse_zephyr_version_file,
|
|
167
|
+
posix_venv_unusable,
|
|
168
|
+
reported_missing,
|
|
169
|
+
select_linux_install,
|
|
170
|
+
)
|
|
171
|
+
from tan.core.consent import can_prompt
|
|
172
|
+
from tan.core import devicetree_lint
|
|
173
|
+
from tan.core.doctor_git import (
|
|
174
|
+
_git_behind_upstream,
|
|
175
|
+
_git_core_longpaths,
|
|
176
|
+
_git_short_commit,
|
|
177
|
+
_is_own_git_checkout,
|
|
178
|
+
_resolve_git_executable,
|
|
179
|
+
classify_git_core_longpaths,
|
|
180
|
+
)
|
|
181
|
+
from tan.core.doctor_stale import StaleVerdict, running_tan_verdict
|
|
182
|
+
from tan.core.doctor_libraries import LibraryReport, inspect_selection
|
|
183
|
+
from tan.core.doctor_render import render_check_lines, render_doctor_footer
|
|
184
|
+
from tan.core.doctor_scope import CHECK_SCOPES
|
|
185
|
+
from tan.core.doctor_setools import (
|
|
186
|
+
FLOW_A_METHOD,
|
|
187
|
+
FLOW_D_METHOD,
|
|
188
|
+
project_flash,
|
|
189
|
+
)
|
|
190
|
+
from tan.core.doctor_setools import verdict as setools_verdict
|
|
191
|
+
from tan.core.global_flags import accept_global_flags
|
|
192
|
+
from tan.core.inert import COMPATIBILITY, inert_help
|
|
193
|
+
from tan.core.python_floor import (
|
|
194
|
+
FALLBACK_PYTHON_FLOOR,
|
|
195
|
+
ZEPHYR_PYTHON_FLOOR,
|
|
196
|
+
effective_python_floor,
|
|
197
|
+
zephyr_python_floor,
|
|
198
|
+
)
|
|
199
|
+
from tan.core.host_python import (
|
|
200
|
+
describe_unsuitable,
|
|
201
|
+
probe_all_host_pythons,
|
|
202
|
+
select_host_python,
|
|
203
|
+
)
|
|
204
|
+
from tan.core.probe import PROBE_TIMEOUT_S, probe, probe_status
|
|
205
|
+
from tan.core.subprocess_env import spawn_env
|
|
206
|
+
from tan.core.sdk_default_registry import registry_path
|
|
207
|
+
from tan.core.sdk_discovery import (
|
|
208
|
+
SDK_DISCOVERY_DIVERGENT,
|
|
209
|
+
_abs_posix,
|
|
210
|
+
_has_loader_script,
|
|
211
|
+
_home_alp_dir,
|
|
212
|
+
_pointer_target,
|
|
213
|
+
discover_sdk_root,
|
|
214
|
+
global_default_foreign_project_issue,
|
|
215
|
+
project_pin_issue,
|
|
216
|
+
resolve_sdk_root_ladder,
|
|
217
|
+
resolve_sdk_root_wide,
|
|
218
|
+
)
|
|
219
|
+
from tan.core.shapes import is_sdk_root, rejected_sdk_root_message
|
|
220
|
+
from tan.core.timestamp import generated_at_iso
|
|
221
|
+
from tan.core import toolchain_provision
|
|
222
|
+
from tan.core.tool_lookup import resolve_tool
|
|
223
|
+
from tan.commands.workspace_patch_check import (
|
|
224
|
+
APPLIED as WORKSPACE_PATCHES_APPLIED,
|
|
225
|
+
MISSING as WORKSPACE_PATCHES_MISSING,
|
|
226
|
+
PatchCheck,
|
|
227
|
+
check_workspace_patches,
|
|
228
|
+
)
|
|
229
|
+
from tan.core.west_patches import describe_unapplied, patch_fix_text, zephyr_base_note
|
|
230
|
+
from tan.core.venv import find_workspace_venv, venv_bin_dir, west_program, west_workspace_dir
|
|
231
|
+
from tan.core.west_workspace_refusal import unresolved_workspace_verdict
|
|
232
|
+
from tan.env import TEXT_WRAP_MIN_WIDTH, stderr_is_tty, stdin_is_tty, terminal_width, use_color
|
|
233
|
+
from tan.envelope import Envelope, Issue, Project, SdkInfo, emit
|
|
234
|
+
from tan.exit_codes import ExitCode
|
|
235
|
+
from tan.output_format import FORMAT_HELP, OutputFormat
|
|
236
|
+
|
|
237
|
+
#: The SETOOLS executables `alif_flash.py` looks for inside `$SETOOLS_DIR`
|
|
238
|
+
#: (its `--app-gen-toc` / `--app-write-mram` defaults).
|
|
239
|
+
SETOOLS_EXECUTABLES = ("app-gen-toc", "app-write-mram")
|
|
240
|
+
|
|
241
|
+
#: The Alif developer-portal bundle `$SETOOLS_DIR` must point INTO. The `-linux`
|
|
242
|
+
#: is not incidental: `alif_flash.py` hard-codes `app-release-exec-linux` in the
|
|
243
|
+
#: refusal it raises, so this path is Linux-only in this tree.
|
|
244
|
+
SETOOLS_BUNDLE = "app-release-exec-linux-SE_FW_x.y.z"
|
|
245
|
+
|
|
246
|
+
#: The J-Link DLL that first shipped Alif's built-in MRAM flash loader. Below
|
|
247
|
+
#: this, Flow D has nothing to program MRAM with.
|
|
248
|
+
JLINK_MIN_DLL = (9, 46)
|
|
249
|
+
|
|
250
|
+
#: The device profile that UNLOCKS that loader. The generic `Cortex-M55` profile
|
|
251
|
+
#: connects fine for read/attach/RAM-run and has no MRAM loader at all, so a
|
|
252
|
+
#: Flow D burn against it silently is not one.
|
|
253
|
+
JLINK_AEN_DEVICE = "AE822FA0E5597LS0_M55_HE"
|
|
254
|
+
|
|
255
|
+
#: The Zephyr SDK release `west sdk install --version` pins. Mirrors
|
|
256
|
+
#: `tan_core::host_env::ZEPHYR_SDK_INSTALL_VERSION` byte-for-byte, so the
|
|
257
|
+
#: `zephyrSdk` check's fix hint below and the Rust oracle's own can never name
|
|
258
|
+
#: two different versions.
|
|
259
|
+
#:
|
|
260
|
+
#: A NEW consumer of the pin `contract/fixtures/toolchains/toolchains.json`
|
|
261
|
+
#: owns -- that fixture's own `_comment` states the rule verbatim: "A NEW
|
|
262
|
+
#: consumer of this pin needs its own parity assertion; widening this scan
|
|
263
|
+
#: will not reach it." `test_zephyr_sdk_install_version_matches_the_real_
|
|
264
|
+
#: toolchain_lock` (test_doctor_command.py) is that assertion, mirroring
|
|
265
|
+
#: `crates/tan-core/src/host_env.rs`'s test of the same name (tan-cli#172) --
|
|
266
|
+
#: without it, an alp-sdk version bump makes Rust fail loudly and this
|
|
267
|
+
#: constant go silently stale.
|
|
268
|
+
ZEPHYR_SDK_INSTALL_VERSION = "1.0.1"
|
|
269
|
+
|
|
270
|
+
#: PATH names west's `.7z` toolchain extraction (via patoolib, which shells
|
|
271
|
+
#: out to an external binary with no pure-Python fallback) will accept --
|
|
272
|
+
#: mirrors `crate::build_readiness::SEVEN_ZIP_PROGRAMS` byte-for-byte. Any ONE
|
|
273
|
+
#: is enough; probing only `7z` would false-negative a host that has `7zz` or
|
|
274
|
+
#: `unar` instead.
|
|
275
|
+
SEVEN_ZIP_PROGRAMS = ("7z", "7za", "7zr", "7zz", "7zzs", "unar")
|
|
276
|
+
|
|
277
|
+
#: Verified resolvable (`winget show 7zip.7zip` -> `Found 7-Zip [7zip.7zip]`,
|
|
278
|
+
#: publisher Igor Pavlov) -- mirrors `crate::build_readiness::
|
|
279
|
+
#: SEVEN_ZIP_INSTALL_COMMAND` byte-for-byte.
|
|
280
|
+
SEVEN_ZIP_INSTALL_COMMAND = "winget install -e --id 7zip.7zip"
|
|
281
|
+
|
|
282
|
+
#: The host platforms the pinned Zephyr SDK (`ZEPHYR_SDK_INSTALL_VERSION`
|
|
283
|
+
#: above) actually publishes a build for -- mirrors
|
|
284
|
+
#: `tan_core::host_env::ZEPHYR_SDK_HOSTS` byte-for-byte (tan-cli#294 finding
|
|
285
|
+
#: 1, reintroducing tan-cli#70). `windows-arm64` was never published at any
|
|
286
|
+
#: release; `macos-x86_64` was dropped in the 1.0.0 line the pinned SDK is
|
|
287
|
+
#: past. Spelled in the SDK's own release-asset tokens (`x86_64`, not `x64`).
|
|
288
|
+
ZEPHYR_SDK_HOSTS = ("linux-aarch64", "linux-x86_64", "macos-aarch64", "windows-x86_64")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class Check:
|
|
293
|
+
"""One verdict. `status` is the Rust `DoctorStatus` vocabulary verbatim:
|
|
294
|
+
`pass` / `warn` / `fail` / `unknown`, where `unknown` means the question was
|
|
295
|
+
not askable on this host -- counted in NO summary bucket and raising no
|
|
296
|
+
issue, so an unverifiable assumption is never rendered as observed fact.
|
|
297
|
+
|
|
298
|
+
`code` overrides the default `doctor.<name>` issue code. It exists for the
|
|
299
|
+
three FROZEN `bootstrap.*` codes (`contract/issue-codes.json`), which
|
|
300
|
+
`alp-sdk-vscode`'s `PREREQ_CODES` matches with `Set.has()` -- an unrecognised
|
|
301
|
+
code there is indistinguishable from "no problem", so the spelling is load-
|
|
302
|
+
bearing and must not be re-derived from the check name.
|
|
303
|
+
|
|
304
|
+
`missing` carries the structured per-tool form of a `hostPrerequisites`
|
|
305
|
+
refusal (tan-cli#294 finding 4: `data.missingPrerequisites`) -- NOT
|
|
306
|
+
serialized by `as_dict()` below, unlike every other field: it does not
|
|
307
|
+
ride on the per-check JSON at all (mirroring Rust's `DoctorCheck`, which
|
|
308
|
+
has no such field either), only on the report-level
|
|
309
|
+
`data.missingPrerequisites` `doctor()` builds from it. Since tan-cli#760
|
|
310
|
+
it is the tan-cli#760-CONFIRMED half: a tool whose manifest command's own
|
|
311
|
+
leading binary (`sudo` included) does not resolve on this host's PATH
|
|
312
|
+
reports `command: null` here, never a string a human or `alp-sdk-
|
|
313
|
+
vscode`'s Fix button could run and fail on.
|
|
314
|
+
|
|
315
|
+
`fix_missing` is `missing`'s RAW twin, tan-cli#760 review MAJOR 1: it
|
|
316
|
+
carries the SAME `{tool, command}` pairs `missing` would carry with no
|
|
317
|
+
confirmation guard applied at all, and is what `doctor()`'s `--fix`
|
|
318
|
+
branch feeds `run_fix` -- never `missing` itself. `run_fix` has done its
|
|
319
|
+
OWN PATH resolution and reported `doctor.fix-installer-not-found` since
|
|
320
|
+
tan-cli#360; feeding it the CONFIRMED (already-nulled) dict instead would
|
|
321
|
+
turn that diagnosis into a silent no-op the moment the installer itself
|
|
322
|
+
is unconfirmed -- exactly the `--fix` silence #360 exists to prevent, on
|
|
323
|
+
the very host (a fresh Mac with no Homebrew, a Windows image with no
|
|
324
|
+
usable winget) #360 was written for. Also NOT serialized, for the same
|
|
325
|
+
reason `missing` is not: it exists purely for `--fix`'s in-process input,
|
|
326
|
+
never the wire.
|
|
327
|
+
|
|
328
|
+
A `hostPrerequisites` Check built with only `missing=` (omitting
|
|
329
|
+
`fix_missing=`) is not left silently `--fix`-disabled: `__post_init__`
|
|
330
|
+
falls back to `missing` itself when `fix_missing` is `None`, matching
|
|
331
|
+
`prerequisites_check`'s own `available=None` behaviour -- the two fields
|
|
332
|
+
equal, pre-#760's shape. A future 9th construction site that forgets the
|
|
333
|
+
pairing therefore fails SAFE (pre-#760 `--fix` behaviour) rather than
|
|
334
|
+
silently.
|
|
335
|
+
|
|
336
|
+
`scope` (tan-cli#549) is `host` or `project` -- see `tan.core.doctor_scope`
|
|
337
|
+
for the two definitions and the judgement calls. REQUIRED and KEYWORD-ONLY,
|
|
338
|
+
which is the enforcement mechanism itself: a check authored without one is
|
|
339
|
+
a `TypeError` at its own construction site, in every branch, on every
|
|
340
|
+
platform, including the ones no test host reaches (`longPaths`, `sevenZip`,
|
|
341
|
+
the six `fix:*` outcomes). A default -- any default -- would put the
|
|
342
|
+
consumer straight back on a hand-list for whatever the default silently
|
|
343
|
+
mislabelled, which is the failure this field exists to end. Keyword-only so
|
|
344
|
+
it can be required without renumbering the positional `fix` argument a
|
|
345
|
+
dozen call sites here already pass positionally.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
name: str
|
|
349
|
+
status: str
|
|
350
|
+
detail: str
|
|
351
|
+
fix: str | None = None
|
|
352
|
+
code: str | None = None
|
|
353
|
+
#: `str | int | None` values since tan-cli#1066: an entry carries
|
|
354
|
+
#: `sizeBytes` (an int, or `null`) alongside its two string fields.
|
|
355
|
+
missing: list[dict[str, str | int | None]] | None = None
|
|
356
|
+
fix_missing: list[dict[str, str | int | None]] | None = None
|
|
357
|
+
scope: str = field(kw_only=True)
|
|
358
|
+
|
|
359
|
+
def __post_init__(self) -> None:
|
|
360
|
+
# A `str` annotation does not stop `scope=""` or a typo'd `"Host"`, and
|
|
361
|
+
# a consumer filtering on `scope` cannot tell an unrecognised value from
|
|
362
|
+
# a row it is meant to hide -- the same fail-open silence the
|
|
363
|
+
# name-matching seam had. Refuse at construction instead. Every value in
|
|
364
|
+
# this package is a literal that `test_doctor_check_scope.py` already
|
|
365
|
+
# checks statically, so this arm is unreachable from shipping code; it
|
|
366
|
+
# is here for a caller that computes one.
|
|
367
|
+
if self.scope not in CHECK_SCOPES:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"doctor check {self.name!r} declares scope {self.scope!r}; "
|
|
370
|
+
f"the wire vocabulary is {CHECK_SCOPES}"
|
|
371
|
+
)
|
|
372
|
+
# tan-cli#760 review round 3, MINOR: a `hostPrerequisites` Check
|
|
373
|
+
# authored with only `missing=` (a 9th call site nobody remembered to
|
|
374
|
+
# pair with `fix_missing=`) must not silently disable `--fix` --
|
|
375
|
+
# `object.__setattr__` because `Check` is frozen, and this is the one
|
|
376
|
+
# place a divergent-by-omission construction can still be caught and
|
|
377
|
+
# repaired rather than reddening `run_fix` with a `None` input. Falls
|
|
378
|
+
# back to `missing` itself, matching `prerequisites_check`'s own
|
|
379
|
+
# `available=None` behaviour (pre-#760: the two fields identical) --
|
|
380
|
+
# fails SAFE at pre-#760 `--fix` behaviour, never silent. A no-op
|
|
381
|
+
# when `missing` is also `None` (every non-`hostPrerequisites` Check).
|
|
382
|
+
if self.fix_missing is None:
|
|
383
|
+
object.__setattr__(self, "fix_missing", self.missing)
|
|
384
|
+
|
|
385
|
+
def as_dict(self) -> dict:
|
|
386
|
+
# `scope` between `status` and `detail`: the three machine-read fields
|
|
387
|
+
# first, the prose last. Key ORDER is not a contract on this seam
|
|
388
|
+
# (`contract/README.md`, "What a golden does NOT cover: key ORDER") --
|
|
389
|
+
# both conformance harnesses compare parsed maps -- so this is
|
|
390
|
+
# readability, not compatibility.
|
|
391
|
+
out = {
|
|
392
|
+
"name": self.name,
|
|
393
|
+
"status": self.status,
|
|
394
|
+
"scope": self.scope,
|
|
395
|
+
"detail": self.detail,
|
|
396
|
+
}
|
|
397
|
+
# Omitted when absent, not null -- Rust's `skip_serializing_if`.
|
|
398
|
+
if self.fix is not None:
|
|
399
|
+
out["fix"] = self.fix
|
|
400
|
+
return out
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# Probing. `probe_status`/`probe` (tan.core.probe) are every subprocess this
|
|
405
|
+
# module's checks read a tool's own answer through; `on_path` below is the
|
|
406
|
+
# PATH-only presence walk. Every filesystem read goes through one of these
|
|
407
|
+
# three, and none of them can raise. `probe_status`/`probe`/`PROBE_TIMEOUT_S`
|
|
408
|
+
# moved to `tan.core.probe` (tan-cli#797 module-size budget) so
|
|
409
|
+
# `tan.core.doctor_git` could depend on them without an import cycle back
|
|
410
|
+
# into this file; re-imported here so every existing `probe(...)` call site
|
|
411
|
+
# below, and every test referencing `doctor_cmd.probe`/`doctor_cmd.
|
|
412
|
+
# PROBE_TIMEOUT_S`, keeps resolving unchanged.
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def on_path(command: str) -> str | None:
|
|
417
|
+
"""Resolve `command` against `$PATH`, returning its full path.
|
|
418
|
+
|
|
419
|
+
NOT `shutil.which`: on Windows that inserts `os.curdir` ahead of PATH
|
|
420
|
+
(documented Windows search order), so a project checked out with its own
|
|
421
|
+
`west.exe`/`openocd.exe` at its root would be reported as this host's
|
|
422
|
+
tooling -- and a later flow would spawn exactly that project-controlled
|
|
423
|
+
binary.
|
|
424
|
+
|
|
425
|
+
ONE LOOKUP, NOT A SIXTH COPY (tan-cli#532). This used to hand-roll the
|
|
426
|
+
walk; it now delegates to `tan.core.tool_lookup.resolve_tool`, which
|
|
427
|
+
`build/execute.py`, `flash_cmd.py` and `size_cmd.py` already call.
|
|
428
|
+
`faultdecode_cmd` reaches the lookup THROUGH here, so this retires the
|
|
429
|
+
last two of the five copies. Only the FINDING is shared -- the signature
|
|
430
|
+
stays `str | None` so each caller keeps its own policy about a miss; ask
|
|
431
|
+
`resolve_tool` directly for its `searched` record.
|
|
432
|
+
|
|
433
|
+
WINDOWS BEHAVIOUR CHANGED, deliberately: the old copy's `[""] + %PATHEXT%`
|
|
434
|
+
accepted a bare extension-less `%PATH%` file ahead of every suffixed
|
|
435
|
+
sibling, and `resolve_tool` never tries the bare name. Reasoning and the
|
|
436
|
+
oracle comparison are in `tool_lookup`'s docstring; pinned by
|
|
437
|
+
`test_bare_argv0_spawn.py::
|
|
438
|
+
test_the_windows_walk_never_considers_the_bare_extensionless_name`, and
|
|
439
|
+
the delegation itself by `test_doctor_on_path_consolidation.py`. POSIX is
|
|
440
|
+
unaffected.
|
|
441
|
+
|
|
442
|
+
`os.environ`, not a threaded env: this is the doctor's HOST probe. Paths
|
|
443
|
+
resolving against a constructed environment (a workspace venv prepended)
|
|
444
|
+
call `resolve_tool` with that env and never come through here.
|
|
445
|
+
"""
|
|
446
|
+
return resolve_tool(command, os.environ).resolved
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _read_text(path: Path) -> str | None:
|
|
450
|
+
try:
|
|
451
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
452
|
+
except (OSError, ValueError):
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ---------------------------------------------------------------------------
|
|
457
|
+
# Version floors
|
|
458
|
+
# ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _parse_two(raw: str) -> tuple[int, int] | None:
|
|
462
|
+
"""`"3.12"`, `"v1.2.0"`, `"West version: v1.2.0"` -> `(major, minor)`."""
|
|
463
|
+
match = re.search(r"(\d+)\.(\d+)", raw)
|
|
464
|
+
if match is None:
|
|
465
|
+
return None
|
|
466
|
+
return (int(match.group(1)), int(match.group(2)))
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def jlink_flash_device(sdk_root: str | None) -> tuple[str, str]:
|
|
470
|
+
"""The Flow-D part-number J-Link device profile, and where it came from.
|
|
471
|
+
|
|
472
|
+
Read from `<sdk>/metadata/socs/alif/ensemble/e8.json`
|
|
473
|
+
`variants[].debug.jlink_flash_device` -- the ONE variant carrying that key
|
|
474
|
+
is the one with an MRAM loader profile at all; the other AE822 package
|
|
475
|
+
variant's `debug` has a `jlink_device` (attach) entry but no
|
|
476
|
+
`jlink_flash_device`, because it has no Flow D loader to unlock.
|
|
477
|
+
|
|
478
|
+
`JLINK_AEN_DEVICE` is the fallback for THREE distinct causes, and the
|
|
479
|
+
returned source string names WHICH one fired (tan-cli#310) -- they used
|
|
480
|
+
to collapse into one sentence that only ever matched the first, so a host
|
|
481
|
+
with a perfectly good SDK checkout got told "no alp-sdk checkout
|
|
482
|
+
resolved" in the same envelope that reported resolving one:
|
|
483
|
+
|
|
484
|
+
1. no `sdk_root` at all -- the honest "nothing to read from" case;
|
|
485
|
+
2. `sdk_root` resolved but `e8.json` is missing, unreadable, or does not
|
|
486
|
+
parse as a JSON object -- named with the exact path that was tried;
|
|
487
|
+
3. `sdk_root` resolved and `e8.json` parsed fine, but no variant carries
|
|
488
|
+
`debug.jlink_flash_device` -- the real state of a checkout predating
|
|
489
|
+
alp-sdk#1057, which publishes this fact into a per-board `flash_args`
|
|
490
|
+
value instead; doctor has no board selected to read one from, so the
|
|
491
|
+
built-in constant is the honest answer, not a resolution failure.
|
|
492
|
+
|
|
493
|
+
Every variant is checked, not just the first hit: if a future package
|
|
494
|
+
variant declares a DIFFERENT `jlink_flash_device`, picking whichever
|
|
495
|
+
serialises first would silently advise the wrong part with nothing to
|
|
496
|
+
catch it. More than one DISTINCT value is ambiguous, not resolved -- it
|
|
497
|
+
falls back to `JLINK_AEN_DEVICE` with a source that says so, rather than
|
|
498
|
+
guessing.
|
|
499
|
+
|
|
500
|
+
Never raises: a missing SDK, an unreadable or malformed `e8.json`, or no
|
|
501
|
+
variant carrying the key all fall back the same way -- doctor's whole job
|
|
502
|
+
is to run on a host where things are wrong.
|
|
503
|
+
"""
|
|
504
|
+
if not sdk_root:
|
|
505
|
+
return JLINK_AEN_DEVICE, (
|
|
506
|
+
f"tan's built-in fallback {JLINK_AEN_DEVICE} -- no alp-sdk checkout "
|
|
507
|
+
"resolved to read metadata/socs/alif/ensemble/e8.json "
|
|
508
|
+
"variants[].debug.jlink_flash_device from"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
path = Path(sdk_root) / "metadata" / "socs" / "alif" / "ensemble" / "e8.json"
|
|
512
|
+
text = _read_text(path)
|
|
513
|
+
doc = None
|
|
514
|
+
if text is not None:
|
|
515
|
+
try:
|
|
516
|
+
doc = json.loads(text)
|
|
517
|
+
except ValueError:
|
|
518
|
+
doc = None
|
|
519
|
+
if not isinstance(doc, dict):
|
|
520
|
+
return JLINK_AEN_DEVICE, (
|
|
521
|
+
f"tan's built-in fallback {JLINK_AEN_DEVICE} -- {path} is missing, "
|
|
522
|
+
"unreadable, or did not parse as a JSON object, so its "
|
|
523
|
+
"variants[].debug.jlink_flash_device could not be read"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
found: set[str] = set()
|
|
527
|
+
for variant in doc.get("variants") or []:
|
|
528
|
+
if not isinstance(variant, dict):
|
|
529
|
+
continue
|
|
530
|
+
debug = variant.get("debug")
|
|
531
|
+
device = debug.get("jlink_flash_device") if isinstance(debug, dict) else None
|
|
532
|
+
if isinstance(device, str) and device:
|
|
533
|
+
found.add(device)
|
|
534
|
+
if len(found) == 1:
|
|
535
|
+
return next(iter(found)), str(path)
|
|
536
|
+
if len(found) > 1:
|
|
537
|
+
return JLINK_AEN_DEVICE, (
|
|
538
|
+
f"tan's built-in fallback {JLINK_AEN_DEVICE} -- {path} "
|
|
539
|
+
f"variants[].debug.jlink_flash_device carries {len(found)} "
|
|
540
|
+
"DIFFERENT values across variants (ambiguous), refusing to "
|
|
541
|
+
"pick one arbitrarily"
|
|
542
|
+
)
|
|
543
|
+
return JLINK_AEN_DEVICE, (
|
|
544
|
+
f"tan's built-in fallback {JLINK_AEN_DEVICE} -- {path} parsed but no "
|
|
545
|
+
"variant carries debug.jlink_flash_device; alp-sdk#1057 publishes this "
|
|
546
|
+
"profile into a per-board flash_args value instead, and doctor has no "
|
|
547
|
+
"board selected to read one from"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _fmt(version: tuple[int, int]) -> str:
|
|
552
|
+
return f"{version[0]}.{version[1]}"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ---------------------------------------------------------------------------
|
|
556
|
+
# The checks. Pure: probed facts in, a verdict out.
|
|
557
|
+
# ---------------------------------------------------------------------------
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def python_check(
|
|
561
|
+
found: tuple[str, tuple[int, int]] | None, floor: tuple[int, int], floor_source: str
|
|
562
|
+
) -> Check:
|
|
563
|
+
"""`hostPython` -- is there an interpreter, and does it clear the EFFECTIVE
|
|
564
|
+
floor?
|
|
565
|
+
|
|
566
|
+
`found` is `(how it is spelled, (major, minor))` for the best candidate that
|
|
567
|
+
actually RAN. `None` is not "too old", it is "nothing runs": the Microsoft
|
|
568
|
+
Store `python.exe` alias satisfies any presence check and prints nothing,
|
|
569
|
+
which is why the probe insists on parseable output rather than existence.
|
|
570
|
+
"""
|
|
571
|
+
if found is None:
|
|
572
|
+
return Check(
|
|
573
|
+
"hostPython",
|
|
574
|
+
"fail",
|
|
575
|
+
"no runnable Python interpreter found -- none of `python3`/`python`"
|
|
576
|
+
+ (" / `py -3`" if os.name == "nt" else "")
|
|
577
|
+
+ " ran and reported a version.",
|
|
578
|
+
"Install Python "
|
|
579
|
+
+ _fmt(floor)
|
|
580
|
+
+ "+ and put it on PATH."
|
|
581
|
+
+ (
|
|
582
|
+
" On Windows, a `python.exe` that opens the Microsoft Store is the"
|
|
583
|
+
" Store ALIAS, not an interpreter: disable it under Settings > Apps >"
|
|
584
|
+
" App execution aliases, or install from python.org."
|
|
585
|
+
if os.name == "nt"
|
|
586
|
+
else ""
|
|
587
|
+
),
|
|
588
|
+
# FROZEN (contract/issue-codes.json). Spelled, never derived.
|
|
589
|
+
code="bootstrap.python-not-runnable",
|
|
590
|
+
scope="host",
|
|
591
|
+
)
|
|
592
|
+
binary, version = found
|
|
593
|
+
if version < floor:
|
|
594
|
+
return Check(
|
|
595
|
+
"hostPython",
|
|
596
|
+
"fail",
|
|
597
|
+
f"Python {_fmt(version)} (`{binary}`) is below the effective floor "
|
|
598
|
+
f"{_fmt(floor)}, which comes from {floor_source}. The build does not "
|
|
599
|
+
f"fail here -- it fails later, inside Zephyr's own CMake configure, "
|
|
600
|
+
f"with an error that names Zephyr rather than your Python.",
|
|
601
|
+
f"Install Python {_fmt(floor)}+ and put it ahead of {_fmt(version)} on PATH, "
|
|
602
|
+
f"then re-run `tan bootstrap` so the workspace venv is built with it."
|
|
603
|
+
+ (
|
|
604
|
+
# Named because it is THE case: the distro `python3` on 22.04 is
|
|
605
|
+
# 3.10, which clears the manifest floor and dies at Zephyr's
|
|
606
|
+
# configure -- the exact host this check exists for.
|
|
607
|
+
f" Ubuntu 22.04's distro `python3` is 3.10, so this needs a newer one: "
|
|
608
|
+
f"`sudo add-apt-repository ppa:deadsnakes/ppa && sudo apt-get install -y "
|
|
609
|
+
f"python{_fmt(floor)} python{_fmt(floor)}-venv`."
|
|
610
|
+
if sys.platform.startswith("linux")
|
|
611
|
+
else ""
|
|
612
|
+
),
|
|
613
|
+
# FROZEN (contract/issue-codes.json).
|
|
614
|
+
code="bootstrap.python-too-old",
|
|
615
|
+
scope="host",
|
|
616
|
+
)
|
|
617
|
+
return Check(
|
|
618
|
+
"hostPython",
|
|
619
|
+
"pass",
|
|
620
|
+
f"Python {_fmt(version)} (`{binary}`) meets the effective floor "
|
|
621
|
+
f"{_fmt(floor)} ({floor_source}).",
|
|
622
|
+
scope="host",
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def host_python_check(
|
|
627
|
+
found: tuple[str, tuple[int, int]] | None,
|
|
628
|
+
floor: tuple[int, int],
|
|
629
|
+
floor_source: str,
|
|
630
|
+
has_workspace_venv: bool,
|
|
631
|
+
candidates: list | None = None,
|
|
632
|
+
) -> Check:
|
|
633
|
+
"""`hostPython` as `python_check` judges it, downgraded to `warn` (same id,
|
|
634
|
+
no code) when it would pass but `tan build` would have no usable
|
|
635
|
+
interpreter: no workspace venv, and no PATH Python that is >= `floor` AND
|
|
636
|
+
imports `west` (tan-cli#1317, same resolver as the build)."""
|
|
637
|
+
check = python_check(found, floor, floor_source)
|
|
638
|
+
if check.status != "pass" or has_workspace_venv:
|
|
639
|
+
return check
|
|
640
|
+
if candidates is None:
|
|
641
|
+
candidates = probe_all_host_pythons()
|
|
642
|
+
candidates = list(candidates)
|
|
643
|
+
best = select_host_python(candidates, floor, need_west=True)
|
|
644
|
+
if best is not None and best.version >= floor and best.has_west:
|
|
645
|
+
return check
|
|
646
|
+
return Check(
|
|
647
|
+
"hostPython",
|
|
648
|
+
"warn",
|
|
649
|
+
f"{check.detail} But {describe_unsuitable(candidates, floor)}",
|
|
650
|
+
"Run `tan bootstrap` (or activate the Zephyr workspace venv).",
|
|
651
|
+
scope="host",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def python_floor_skew_check(
|
|
656
|
+
manifest_floor: tuple[int, int],
|
|
657
|
+
effective_floor: tuple[int, int],
|
|
658
|
+
effective_source: str,
|
|
659
|
+
manifest_is_real: bool = True,
|
|
660
|
+
) -> Check | None:
|
|
661
|
+
"""`pythonFloor` -- the two declared floors disagree.
|
|
662
|
+
|
|
663
|
+
Reported rather than silently reconciled. A host that satisfies the higher
|
|
664
|
+
floor is fine TODAY, but the manifest is the number a customer will read and
|
|
665
|
+
trust, so while the skew stands the two sources disagree about which hosts
|
|
666
|
+
are supported. Saying which number came from which file is the whole value.
|
|
667
|
+
|
|
668
|
+
**Not fixed by raising the manifest (tan-cli#300).** That was tried and
|
|
669
|
+
reverted -- alp-sdk#1078: `crates/tan-core/src/build_readiness.rs:401`
|
|
670
|
+
pushes the Python check BEFORE any `os_set` branch ("EVERY backend's
|
|
671
|
+
build-plan emission runs `alp_project.py` ... not just Zephyr's"), so
|
|
672
|
+
raising the shared `pythonMinVersion` key would refuse a Yocto-only or
|
|
673
|
+
metadata-only project, on a host that builds it fine today, over a floor
|
|
674
|
+
that project never needs -- and the raised floor is unreachable via the
|
|
675
|
+
manifest's own remedy (`sudo apt-get install -y python3`) on the Ubuntu
|
|
676
|
+
22.04 hosts the docs recommend. The skew is real and known, and scoped to
|
|
677
|
+
Zephyr; the fix for a Zephyr build on a below-floor host is a newer
|
|
678
|
+
interpreter on THAT host (see `hostPython` above), not a manifest edit.
|
|
679
|
+
|
|
680
|
+
`manifest_is_real` is `False` when `manifest_floor` never actually came from
|
|
681
|
+
a read `metadata/bootstrap.json` -- no SDK resolved, or this SDK predates
|
|
682
|
+
the manifest -- and is instead tan's own `FALLBACK_PYTHON_FLOOR` standing
|
|
683
|
+
in. Callers pass `_load_manifest`'s own `ManifestLoad.is_real` verdict
|
|
684
|
+
straight through -- never re-derived from `ManifestLoad.source`'s prose, so
|
|
685
|
+
a future rewording of that message cannot silently flip which branch below
|
|
686
|
+
fires. Misreporting that number as "alp-sdk's metadata/bootstrap.json
|
|
687
|
+
declares" sends the customer to edit a file that was never consulted, so
|
|
688
|
+
the wording and the fix both change for this case.
|
|
689
|
+
|
|
690
|
+
`tan bootstrap` enforces the SAME effective floor this reports -- it calls
|
|
691
|
+
`zephyr_python_floor` below with the same argument (see
|
|
692
|
+
`tan.commands.bootstrap_cmd.resolve_python_floor`) and raises
|
|
693
|
+
`bootstrap.python-floor-skew` with the same two numbers. Before that, the
|
|
694
|
+
Rust oracle's POSIX branch enforced only the manifest's, which is how a
|
|
695
|
+
3.10 host passed both commands and then died inside Zephyr's CMake
|
|
696
|
+
configure.
|
|
697
|
+
"""
|
|
698
|
+
if manifest_floor >= effective_floor:
|
|
699
|
+
return None
|
|
700
|
+
if manifest_is_real:
|
|
701
|
+
claim = f"alp-sdk's metadata/bootstrap.json declares pythonMinVersion {_fmt(manifest_floor)}"
|
|
702
|
+
fix = (
|
|
703
|
+
f"Known, Zephyr-scoped skew (alp-sdk#1078) -- raising "
|
|
704
|
+
f"`prerequisites.pythonMinVersion` to {_fmt(effective_floor)} was tried "
|
|
705
|
+
f"and reverted, because that key also gates Yocto-only and "
|
|
706
|
+
f"metadata-only projects, which do not need it. Building for Zephyr on "
|
|
707
|
+
f"a host below {_fmt(effective_floor)} needs a newer interpreter -- see "
|
|
708
|
+
f"the `hostPython` check above."
|
|
709
|
+
)
|
|
710
|
+
else:
|
|
711
|
+
claim = (
|
|
712
|
+
f"no alp-sdk metadata/bootstrap.json was read (no SDK checkout resolved, "
|
|
713
|
+
f"or this SDK predates it), so tan's own built-in floor {_fmt(manifest_floor)} "
|
|
714
|
+
f"is standing in"
|
|
715
|
+
)
|
|
716
|
+
fix = (
|
|
717
|
+
# `tan sdk switch` refuses in this build (tan-cli#305) -- point at
|
|
718
|
+
# the mechanism that actually resolves one instead.
|
|
719
|
+
f"Resolve an alp-sdk checkout: {NO_SDK_NEXT_STEPS}. That checkout's "
|
|
720
|
+
"own metadata/bootstrap.json pythonMinVersion is then read instead "
|
|
721
|
+
"of tan's built-in floor."
|
|
722
|
+
)
|
|
723
|
+
return Check(
|
|
724
|
+
"pythonFloor",
|
|
725
|
+
"warn",
|
|
726
|
+
f"{claim}, but the build's effective floor is "
|
|
727
|
+
f"{_fmt(effective_floor)} (from {effective_source}). Both `tan doctor` and "
|
|
728
|
+
f"`tan bootstrap` enforce the higher, effective floor, so a host this "
|
|
729
|
+
f"manifest would have accepted is refused up front rather than failing "
|
|
730
|
+
f"later at Zephyr's CMake configure.",
|
|
731
|
+
fix,
|
|
732
|
+
scope="project",
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def prerequisites_check(
|
|
737
|
+
checked: list[str],
|
|
738
|
+
missing: list[str],
|
|
739
|
+
install: dict[str, str],
|
|
740
|
+
source: str,
|
|
741
|
+
venv_refusal: PrereqFailure | None = None,
|
|
742
|
+
*,
|
|
743
|
+
available: Callable[[str], bool] | None = None,
|
|
744
|
+
provenance: dict[str, ArtifactProvenance] | None = None,
|
|
745
|
+
) -> Check:
|
|
746
|
+
"""`hostPrerequisites` -- the manifest's own tool list, on PATH, PLUS
|
|
747
|
+
(Linux only) whether the interpreter's `venv` module can actually create
|
|
748
|
+
a usable environment (tan-cli#294 finding 3, reintroducing tan-cli#161).
|
|
749
|
+
|
|
750
|
+
Mirrors `tan_core::bootstrap::doctor_prerequisite_check`, including that
|
|
751
|
+
the per-tool install commands come from the manifest rather than being
|
|
752
|
+
spelled here: they are per-platform facts alp-sdk owns.
|
|
753
|
+
|
|
754
|
+
`venv_refusal` is `posix_venv_unusable()` when `python3` is on PATH and
|
|
755
|
+
ran, but its `venv` module cannot create a usable environment because
|
|
756
|
+
`ensurepip` is missing -- the Debian/Ubuntu `python3-venv` package split.
|
|
757
|
+
Before this, `tan doctor` probed bare PATH presence and never
|
|
758
|
+
`ensurepip`, so it passed on a host that then died at `tan bootstrap`
|
|
759
|
+
time. `venv_refusal.missing` (`{tool: "python3-venv", command: ...}`)
|
|
760
|
+
folds into this check's own `missing`/`fix_missing` fields alongside any
|
|
761
|
+
tool-presence entries, so one `data.missingPrerequisites` list
|
|
762
|
+
(finding 4) carries both failure shapes -- never two.
|
|
763
|
+
|
|
764
|
+
`available` (tan-cli#760 + review #765) is the PATH-confirmation guard --
|
|
765
|
+
`install` here is RAW, exactly as the manifest declares it, and `install`
|
|
766
|
+
itself is used UNGUARDED to build `fix_missing`, the field `--fix`
|
|
767
|
+
(`doctor_cmd.run_fix`) actually consumes. `missing` -- `data.
|
|
768
|
+
missingPrerequisites`, the customer-facing field -- is the CONFIRMED
|
|
769
|
+
projection of the same entries: `available(binary)` decides whether a
|
|
770
|
+
command's own leading binary (`sudo` included) resolves on this host
|
|
771
|
+
before it is ever handed out as `command`, never `None`. `available=None`
|
|
772
|
+
(every direct caller before tan-cli#760, and every existing unit test of
|
|
773
|
+
this function) means `missing` and `fix_missing` are identical -- no
|
|
774
|
+
guard at all, matching this function's pre-#760 behaviour exactly.
|
|
775
|
+
|
|
776
|
+
`provenance` (tan-cli#1066) is the manifest's own `artifactProvenance`
|
|
777
|
+
table, `tool -> {tier, licence, sourceUrl, sizeBytes}`, joined onto each
|
|
778
|
+
entry by `tool` -- the identity the entry already carries. It reaches BOTH
|
|
779
|
+
`missing` and `fix_missing`, unlike `available` above: the two differ over
|
|
780
|
+
what tan may promise to RUN, and a licence is not a promise. `None` (every
|
|
781
|
+
caller before #1066, and the fallback manifest, which publishes no
|
|
782
|
+
provenance at all) reports the same `null`s a tool with no entry does --
|
|
783
|
+
`west`, `zephyrSdk`, `setools`, `jlink` and `python3-venv` have none in
|
|
784
|
+
alp-sdk v0.16.0, and that gap is alp-sdk#1574's, not tan's to fill in.
|
|
785
|
+
|
|
786
|
+
This split exists because `run_fix` has done its OWN on_path resolution
|
|
787
|
+
and reported `doctor.fix-installer-not-found` since tan-cli#360: handing
|
|
788
|
+
it the CONFIRMED (already-nulled) dict instead of the raw one would make
|
|
789
|
+
every unconfirmed installer a silent `--fix` no-op -- trading away the
|
|
790
|
+
#360 diagnostic for the #760 fix, on the exact hosts (a fresh Mac with no
|
|
791
|
+
Homebrew, a Windows image with no usable winget) #360 exists for
|
|
792
|
+
(tan-cli#760 review, MAJOR 1).
|
|
793
|
+
"""
|
|
794
|
+
raw_entries = tuple(
|
|
795
|
+
MissingPrerequisite(
|
|
796
|
+
tool, install.get(tool), artifact_provenance.for_tool(provenance, tool)
|
|
797
|
+
)
|
|
798
|
+
for tool in missing
|
|
799
|
+
)
|
|
800
|
+
if venv_refusal is not None:
|
|
801
|
+
# `posix_venv_unusable()`'s own entry keeps its `UNKNOWN` provenance:
|
|
802
|
+
# `python3-venv` is a Debian PACKAGE name, not one of alp-sdk's
|
|
803
|
+
# `artifactProvenance` keys, so there is nothing to join and a
|
|
804
|
+
# near-miss join (`python3`'s row) would attribute one artefact's
|
|
805
|
+
# licence to another (tan-cli#1066).
|
|
806
|
+
raw_entries = raw_entries + venv_refusal.missing
|
|
807
|
+
fix_missing = reported_missing(raw_entries)
|
|
808
|
+
|
|
809
|
+
entries = raw_entries if available is None else confirm_missing(raw_entries, available)
|
|
810
|
+
missing_data = reported_missing(entries)
|
|
811
|
+
confirmed_commands = [m.command for m in entries if m.tool in missing and m.command]
|
|
812
|
+
|
|
813
|
+
if missing:
|
|
814
|
+
# tan-cli#760 review MINOR 4: `tan bootstrap`'s sibling refusal
|
|
815
|
+
# (`bootstrap.posix_refusal`/`_doctor_fix_hint`) already stops
|
|
816
|
+
# promising a remedy it cannot perform when NO missing tool has a
|
|
817
|
+
# confirmed command; this Check's own `fix` prose gets the same
|
|
818
|
+
# treatment, naming the tools rather than falling silent.
|
|
819
|
+
if confirmed_commands:
|
|
820
|
+
fix_text = (
|
|
821
|
+
"Install the missing prerequisites, then run `tan bootstrap`."
|
|
822
|
+
f" {'; '.join(confirmed_commands)}"
|
|
823
|
+
)
|
|
824
|
+
elif available is not None:
|
|
825
|
+
fix_text = (
|
|
826
|
+
f"tan has no confirmed install command for this host: "
|
|
827
|
+
f"{', '.join(missing)}. Install them with your OS's package "
|
|
828
|
+
f"manager and put them on PATH, then run `tan bootstrap`."
|
|
829
|
+
)
|
|
830
|
+
else:
|
|
831
|
+
fix_text = "Install the missing prerequisites, then run `tan bootstrap`."
|
|
832
|
+
return Check(
|
|
833
|
+
"hostPrerequisites",
|
|
834
|
+
"fail",
|
|
835
|
+
f"missing from PATH: {', '.join(missing)} ({source}).",
|
|
836
|
+
fix_text,
|
|
837
|
+
# FROZEN (contract/issue-codes.json).
|
|
838
|
+
code="bootstrap.prerequisites-missing",
|
|
839
|
+
missing=missing_data,
|
|
840
|
+
fix_missing=fix_missing,
|
|
841
|
+
scope="host",
|
|
842
|
+
)
|
|
843
|
+
if venv_refusal is not None:
|
|
844
|
+
return Check(
|
|
845
|
+
"hostPrerequisites",
|
|
846
|
+
"fail",
|
|
847
|
+
f"{' '.join(venv_refusal.lines)} ({source}).",
|
|
848
|
+
"Install the missing prerequisites, then run `tan bootstrap`.",
|
|
849
|
+
code=f"bootstrap.{venv_refusal.code}",
|
|
850
|
+
missing=missing_data,
|
|
851
|
+
fix_missing=fix_missing,
|
|
852
|
+
scope="host",
|
|
853
|
+
)
|
|
854
|
+
return Check(
|
|
855
|
+
"hostPrerequisites", "pass", f"{', '.join(checked)} present ({source}).",
|
|
856
|
+
scope="host",
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _posix_venv_capable(argv: list[str], executable: str | None = None) -> bool:
|
|
861
|
+
"""Whether `argv`'s Python can create a USABLE virtual environment
|
|
862
|
+
(tan-cli#161). `python -m venv --help` cannot tell -- argparse answers
|
|
863
|
+
before `ensurepip` is ever touched -- so this probes the real
|
|
864
|
+
dependency: `import ensurepip`, which fails fast on the Debian/Ubuntu
|
|
865
|
+
split where `python3-venv` is a separate, unmet package.
|
|
866
|
+
|
|
867
|
+
Fails OPEN, not closed (tan-cli#294 review): `True` both when the probe
|
|
868
|
+
ran and exited 0, AND when it could not be launched at all (bogus argv,
|
|
869
|
+
spawn failure, signal death) -- mirrors `crate::util::
|
|
870
|
+
python_venv_capable`'s `.output().map(|out| out.status.success())
|
|
871
|
+
.unwrap_or(true)` verdict, not only its probed command; the real `python
|
|
872
|
+
-m venv` a moment later surfaces its own error if something is genuinely
|
|
873
|
+
wrong. Only a probe that actually RAN and exited non-zero refuses the
|
|
874
|
+
host.
|
|
875
|
+
|
|
876
|
+
NOT built on this file's own `probe()`: `probe()` collapses "ran and
|
|
877
|
+
exited non-zero" and "could not run at all" to the same `None`, and
|
|
878
|
+
those two outcomes need OPPOSITE verdicts here.
|
|
879
|
+
|
|
880
|
+
`executable` -- the PATH-RESOLVED interpreter path (tan-cli#797), e.g.
|
|
881
|
+
`_probe_host_python`'s own third return element. `argv` stays the bare
|
|
882
|
+
display spelling for the child's own `argv[0]`; only the OS-level lookup
|
|
883
|
+
is pinned, the same split every other resolved spawn in this module uses.
|
|
884
|
+
`None` is accepted (rather than required) so a caller that has not
|
|
885
|
+
resolved the interpreter still gets the pre-existing bare-spawn behaviour
|
|
886
|
+
-- there is no bare-git-style "refuse outright" case here, since `argv`
|
|
887
|
+
always comes from a candidate `_probe_host_python` already ran once.
|
|
888
|
+
"""
|
|
889
|
+
try:
|
|
890
|
+
# Empty cwd: `-c` puts the cwd on sys.path (module-hijack, tan-cli#1317).
|
|
891
|
+
with tempfile.TemporaryDirectory(prefix="tan-probe-") as empty:
|
|
892
|
+
result = subprocess.run(
|
|
893
|
+
[*argv, "-c", "import ensurepip"],
|
|
894
|
+
cwd=empty,
|
|
895
|
+
executable=executable,
|
|
896
|
+
capture_output=True,
|
|
897
|
+
text=True,
|
|
898
|
+
encoding="utf-8",
|
|
899
|
+
errors="replace",
|
|
900
|
+
stdin=subprocess.DEVNULL,
|
|
901
|
+
timeout=PROBE_TIMEOUT_S,
|
|
902
|
+
env=spawn_env(),
|
|
903
|
+
check=False,
|
|
904
|
+
)
|
|
905
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
906
|
+
return True
|
|
907
|
+
return result.returncode == 0
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def west_check(
|
|
911
|
+
found: str | None,
|
|
912
|
+
version: tuple[int, int] | None,
|
|
913
|
+
floor: tuple[int, int] | None,
|
|
914
|
+
resolved: str | None = None,
|
|
915
|
+
resolved_ran: bool = True,
|
|
916
|
+
) -> Check:
|
|
917
|
+
"""`west` -- present on BARE PATH, or resolvable through the SAME
|
|
918
|
+
resolver `westResolved` uses (`tan.core.venv.west_program`).
|
|
919
|
+
|
|
920
|
+
**Now consults the resolver (tan-cli#299 second half).** This docstring
|
|
921
|
+
used to argue the opposite:
|
|
922
|
+
|
|
923
|
+
Does NOT assert that the venv resolved one: this check cannot see
|
|
924
|
+
what `westResolved` found... Name the authority instead of
|
|
925
|
+
predicting its answer.
|
|
926
|
+
|
|
927
|
+
That was deliberate at the time: `found` (bare PATH) was this check's
|
|
928
|
+
ONLY signal, so a hard `fail` here on the default post-bootstrap state --
|
|
929
|
+
`tan bootstrap` deliberately does NOT put `west` on PATH; its own
|
|
930
|
+
next-steps text tells the user to activate the venv afterwards -- was a
|
|
931
|
+
false, exit-4 refusal of a host that provably builds. Measured on the
|
|
932
|
+
published v0.5.0-rc2 binary: `tan build` produced real ELFs through the
|
|
933
|
+
resolved venv `west` while this check alone reported the host broken.
|
|
934
|
+
Downgrading that `fail` to `warn` (this file's other, earlier change on
|
|
935
|
+
this branch) fixed the exit code, but the warning still fires on every
|
|
936
|
+
correctly-bootstrapped host's very first `tan doctor` -- and a warning
|
|
937
|
+
that fires on every correct install trains users to ignore warnings,
|
|
938
|
+
which is the same defect as the false `fail`, one severity down
|
|
939
|
+
(hkngln, tan-cli#299).
|
|
940
|
+
|
|
941
|
+
So this now takes `resolved`: the SAME absolute venv path `westResolved`
|
|
942
|
+
already computed via `tan.core.venv.west_program` -- never a second,
|
|
943
|
+
independent probe of its own (`tool_in_venv` already confirmed that file
|
|
944
|
+
exists before `westResolved` ever saw it) -- and reports `pass`, naming
|
|
945
|
+
it, when bare PATH lacks `west` but the resolver found one. A bare-PATH
|
|
946
|
+
probe that cannot see the venv was never a second opinion; it was a
|
|
947
|
+
worse one. It now defers to the real one instead of contradicting it.
|
|
948
|
+
|
|
949
|
+
**Still never the FAIL owner.** When `resolved` is ALSO `None` -- west
|
|
950
|
+
absent from PATH and unresolvable anywhere -- this stays `warn`, not
|
|
951
|
+
`fail`. That severity belongs to `westResolved` alone (below), by
|
|
952
|
+
tan-cli#123's one-version-per-check contract applied to severity: making
|
|
953
|
+
both checks fatal on the same absent-everywhere fact is the two-owners
|
|
954
|
+
bug tan-cli#123 closed, and reintroducing it is exactly what let west
|
|
955
|
+
absent everywhere exit 0 the one time this branch made BOTH checks
|
|
956
|
+
non-fatal at once, before `west_resolved_check` was raised back to
|
|
957
|
+
`fail`. Keeping `west` a `warn` even in that state is what lets
|
|
958
|
+
`westResolved` be the sole, unambiguous reason `tan doctor` exits 4 on a
|
|
959
|
+
genuinely unbuildable host.
|
|
960
|
+
|
|
961
|
+
Only WARN on an old or unreadable version too -- west is forward-
|
|
962
|
+
compatible in practice and refusing a host on a version string we could
|
|
963
|
+
not parse is a worse failure than letting the real invocation report its
|
|
964
|
+
own.
|
|
965
|
+
|
|
966
|
+
**`resolved_ran` -- a resolved-but-unspawnable west is not a `pass`
|
|
967
|
+
(tan-cli#488 defect 3).** `resolved` only proves `westResolved` found a
|
|
968
|
+
file at that path; it says nothing about whether that file can actually
|
|
969
|
+
run. Before this parameter existed, `found is None and resolved is not
|
|
970
|
+
None` returned `pass` unconditionally -- including for a `west` whose
|
|
971
|
+
launcher survives a relocated workspace but whose interpreter does not,
|
|
972
|
+
the exact host `westResolved` (tan-cli#488 defect 1) now reports `fail`
|
|
973
|
+
for. That paired one check correctly failing an unbuildable host with its
|
|
974
|
+
sibling still calling it a `pass`, the headline "pass on an unrunnable
|
|
975
|
+
west" symptom surviving in this one branch. Defaults to `True` so every
|
|
976
|
+
existing 4-argument call site (this file's own unit tests, modelling the
|
|
977
|
+
ordinary "ran fine" case) keeps its prior meaning unchanged.
|
|
978
|
+
"""
|
|
979
|
+
if found is None:
|
|
980
|
+
if resolved is not None and resolved_ran:
|
|
981
|
+
return Check(
|
|
982
|
+
"west",
|
|
983
|
+
"pass",
|
|
984
|
+
f"`west` is not on bare PATH, but resolves through the workspace "
|
|
985
|
+
f"venv: {resolved} -- the same binary `westResolved` above "
|
|
986
|
+
f"reports, and the one a real build actually spawns. This is the "
|
|
987
|
+
f"default state right after `tan bootstrap`, which deliberately "
|
|
988
|
+
f"does not put `west` on PATH; activating the venv (its "
|
|
989
|
+
f"`bin`/`Scripts` directory holds the `west` launcher) would "
|
|
990
|
+
f"additionally put it on bare PATH, for tools that spawn it "
|
|
991
|
+
f"directly rather than through tan.",
|
|
992
|
+
scope="host",
|
|
993
|
+
)
|
|
994
|
+
if resolved is not None:
|
|
995
|
+
return Check(
|
|
996
|
+
"west",
|
|
997
|
+
"warn",
|
|
998
|
+
f"`west` is not on bare PATH. It resolves to {resolved} through the "
|
|
999
|
+
f"workspace venv, but `westResolved` above found that binary could "
|
|
1000
|
+
f"not be executed there -- no build slice can run it. `westResolved` "
|
|
1001
|
+
f"is the check that owns this failure; see it for the remedy.",
|
|
1002
|
+
"Run `tan bootstrap` to recreate the workspace venv.",
|
|
1003
|
+
scope="host",
|
|
1004
|
+
)
|
|
1005
|
+
return Check(
|
|
1006
|
+
"west",
|
|
1007
|
+
"warn",
|
|
1008
|
+
# Does NOT assert that the venv resolved one: `resolved` above
|
|
1009
|
+
# already covers that case with a `pass`, so reaching here means
|
|
1010
|
+
# it is genuinely `None` too -- PATH absence on its own, with
|
|
1011
|
+
# nothing for the resolver to find either. Name the authority
|
|
1012
|
+
# instead of predicting its answer.
|
|
1013
|
+
"`west` is not on bare PATH. `westResolved` above is the check that "
|
|
1014
|
+
"answers whether a build slice can run -- it reports the binary one "
|
|
1015
|
+
"would actually execute. PATH absence on its own is the normal state "
|
|
1016
|
+
"before the workspace venv is activated in this shell.",
|
|
1017
|
+
"If `westResolved` above also could not resolve one, run `tan "
|
|
1018
|
+
"bootstrap`; otherwise activate the workspace venv (its `bin`/`Scripts` "
|
|
1019
|
+
"directory holds the `west` launcher) so tools invoked directly find it "
|
|
1020
|
+
"too.",
|
|
1021
|
+
scope="host",
|
|
1022
|
+
)
|
|
1023
|
+
if version is None:
|
|
1024
|
+
return Check(
|
|
1025
|
+
"west",
|
|
1026
|
+
"warn",
|
|
1027
|
+
f"`west` found at {found} but `west --version` produced nothing this "
|
|
1028
|
+
f"command could parse.",
|
|
1029
|
+
"Run `west --version` by hand; a west that cannot report its version "
|
|
1030
|
+
"usually cannot run either.",
|
|
1031
|
+
scope="host",
|
|
1032
|
+
)
|
|
1033
|
+
if floor is not None and version < floor:
|
|
1034
|
+
return Check(
|
|
1035
|
+
"west",
|
|
1036
|
+
"warn",
|
|
1037
|
+
f"west {_fmt(version)} ({found}) is older than the {_fmt(floor)} floor "
|
|
1038
|
+
f"alp-sdk's metadata/bootstrap.json pins.",
|
|
1039
|
+
"Upgrade inside the workspace venv: `pip install --upgrade west`.",
|
|
1040
|
+
scope="host",
|
|
1041
|
+
)
|
|
1042
|
+
return Check("west", "pass", f"west {_fmt(version)} ({found}).", scope="host")
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def west_resolved_check(
|
|
1046
|
+
found: str | None, version: tuple[int, int] | None, ran: bool = True
|
|
1047
|
+
) -> Check:
|
|
1048
|
+
"""`westResolved` -- is `west` resolved through the WORKSPACE VENV
|
|
1049
|
+
(`tan.core.venv.west_program`), not bare PATH (tan-cli#123/#290)?
|
|
1050
|
+
|
|
1051
|
+
Distinct from `west` above, which probes `on_path("west")` ONLY: on a
|
|
1052
|
+
host where the workspace venv holds `west` but PATH does not -- the
|
|
1053
|
+
normal GUI-launched-editor state, `tan.core.venv`'s own module
|
|
1054
|
+
docstring -- `west` reports failing while a real build succeeds through
|
|
1055
|
+
the venv binary. `westResolved` verifies the SAME binary a build would
|
|
1056
|
+
actually run, and `version` (when probed) MUST come from that identical
|
|
1057
|
+
resolution -- never a second, bare-PATH re-probe. Mirrors
|
|
1058
|
+
`tan_core::preflight::build_preflight_checks`'s `westResolved`
|
|
1059
|
+
(`west_available`) check, unconditional in BOTH doctor modes exactly like
|
|
1060
|
+
`sdk`/`workspace` beside it (`crates/tan-cli/src/commands/doctor.rs:1828`
|
|
1061
|
+
asserts all three together in the plain fold).
|
|
1062
|
+
|
|
1063
|
+
**FAIL when west resolves nowhere.** This used to be a Warn, justified by
|
|
1064
|
+
"`west` above already fails outright on a totally-absent west, so this is
|
|
1065
|
+
the narrower, additive fact". tan-cli#299 removed that Fail -- correctly,
|
|
1066
|
+
because bare PATH is the wrong question -- and thereby falsified the
|
|
1067
|
+
premise this severity rested on. Measured on a real host with `west.exe`
|
|
1068
|
+
renamed out of the venv and absent from PATH:
|
|
1069
|
+
|
|
1070
|
+
westResolved warn west not resolved through the workspace venv or PATH
|
|
1071
|
+
west warn ... every build slice actually resolves it through the venv
|
|
1072
|
+
12 passed, 4 warning(s), 0 failed. EXIT=0
|
|
1073
|
+
|
|
1074
|
+
Exit 0 on a host where nothing can execute a single slice, and `west`'s
|
|
1075
|
+
text asserting the venv resolves it while THIS check says it does not. A
|
|
1076
|
+
false refusal was traded for a false pass, which is the worse of the two.
|
|
1077
|
+
|
|
1078
|
+
So the pair now splits cleanly: `west` answers "is it on bare PATH" and is
|
|
1079
|
+
never fatal (an unactivated venv is the normal post-bootstrap state);
|
|
1080
|
+
`westResolved` answers "can a build slice run at all" and is fatal when the
|
|
1081
|
+
answer is no. Exactly one of them owns the exit code, which is tan-cli#123's
|
|
1082
|
+
one-version-per-check contract applied to severity.
|
|
1083
|
+
|
|
1084
|
+
**`ran` -- spawn failure is ALSO fatal, not a `pass` (tan-cli#488 defect
|
|
1085
|
+
1).** `found` only proves `tool_in_venv`'s `is_file()` check passed; it
|
|
1086
|
+
says nothing about whether the file can actually be EXECUTED. Before
|
|
1087
|
+
`ran` existed, this function took `version` alone and treated
|
|
1088
|
+
`version is None` as `pass` unconditionally -- but `probe()` collapses
|
|
1089
|
+
THREE outcomes into that same `None`: could not be spawned at all
|
|
1090
|
+
(`OSError` -- a `west` launcher whose shebang names a now-absent
|
|
1091
|
+
interpreter, e.g. a relocated/renamed workspace, or a deleted
|
|
1092
|
+
`.venv/bin/python`), spawned and exited non-zero, and spawned, exited
|
|
1093
|
+
zero, and printed something this file's regex could not parse. `pass` is
|
|
1094
|
+
only right for the third. `ran` (from `probe_status`, passed by
|
|
1095
|
+
`_collect`) is `True` for exactly that third case and `False` for the
|
|
1096
|
+
first two, so a resolved-but-unrunnable `west` now reports the SAME
|
|
1097
|
+
`fail` as a `west` that resolved nowhere at all -- both mean "no build
|
|
1098
|
+
slice can be executed" and both send the customer to the same remedy,
|
|
1099
|
+
`tan bootstrap`. Defaults to `True` so every existing 2-argument call
|
|
1100
|
+
site (this file's own unit tests, modelling the ordinary "ran fine"
|
|
1101
|
+
cases) keeps its prior meaning unchanged.
|
|
1102
|
+
"""
|
|
1103
|
+
if found is None:
|
|
1104
|
+
return Check(
|
|
1105
|
+
"westResolved",
|
|
1106
|
+
"fail",
|
|
1107
|
+
"west resolved neither through the workspace venv nor PATH -- no build "
|
|
1108
|
+
"slice can be executed. Run `tan bootstrap` to create the workspace venv.",
|
|
1109
|
+
"tan bootstrap",
|
|
1110
|
+
scope="project",
|
|
1111
|
+
)
|
|
1112
|
+
if not ran:
|
|
1113
|
+
return Check(
|
|
1114
|
+
"westResolved",
|
|
1115
|
+
"fail",
|
|
1116
|
+
f"west resolved to {found}, but it could not be executed there ("
|
|
1117
|
+
f"`{found} --version` did not run to a successful exit) -- no build "
|
|
1118
|
+
f"slice can be executed. A relocated/renamed workspace, or a "
|
|
1119
|
+
f"workspace venv whose own Python was removed, both leave the `west` "
|
|
1120
|
+
f"launcher file in place while breaking it. Run `tan bootstrap` to "
|
|
1121
|
+
f"recreate the workspace venv.",
|
|
1122
|
+
"tan bootstrap",
|
|
1123
|
+
scope="project",
|
|
1124
|
+
)
|
|
1125
|
+
if version is None:
|
|
1126
|
+
return Check("westResolved", "pass", f"west resolved: {found}.", scope="project")
|
|
1127
|
+
return Check("westResolved", "pass", f"west {_fmt(version)} resolved: {found}.", scope="project")
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
#: tan-cli#1520: the verified remedy for a missing Zephyr SDK toolchain.
|
|
1131
|
+
ZEPHYR_SDK_BOOTSTRAP_FIX = "tan bootstrap"
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def zephyr_sdk_install_command() -> str:
|
|
1135
|
+
"""The exact `west sdk install` invocation the `zephyrSdk` check's `fix`
|
|
1136
|
+
names -- the ONE place it is assembled, mirroring
|
|
1137
|
+
`tan_core::zephyr_sdk_install_command` verbatim. `tan bootstrap`'s own
|
|
1138
|
+
"Next steps" text (`tan.core.bootstrap`) already promises "the `tan
|
|
1139
|
+
doctor` above reports it, and names the exact install command"; this is
|
|
1140
|
+
what makes that promise true rather than a second, independently-worded
|
|
1141
|
+
copy able to drift from it.
|
|
1142
|
+
|
|
1143
|
+
tan-cli#1520: this is now the README's MANUAL command only. The
|
|
1144
|
+
`zephyrSdk` check and `tan build`'s missing-SDK message deliberately
|
|
1145
|
+
diverge from the Rust oracle and point at `tan bootstrap` instead, because
|
|
1146
|
+
this command's setup.sh fetches the toolchain archive with no integrity
|
|
1147
|
+
check, skipping the alp-sdk sha256 pin (#1501).
|
|
1148
|
+
"""
|
|
1149
|
+
return f"west sdk install --version {ZEPHYR_SDK_INSTALL_VERSION} -t arm-zephyr-eabi"
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def zephyr_sdk_check(detected: bool, env_dir: str | None = None) -> Check:
|
|
1153
|
+
"""`zephyrSdk` -- is the Zephyr SDK cross toolchain (`arm-zephyr-eabi`)
|
|
1154
|
+
actually installed on this host? Ports `tan_core::zephyr_sdk_toolchain_check`
|
|
1155
|
+
/ `append_zephyr_sdk_toolchain` (tan-cli#160), closing tan-cli#286: this
|
|
1156
|
+
port had NO such check at all, so on a host with no Zephyr SDK `tan
|
|
1157
|
+
doctor` reported "3 passed, 2 warning(s), 0 failed" and never used the
|
|
1158
|
+
word "toolchain" -- the exact alp-sdk#855 fresh-host gap #160 closed in
|
|
1159
|
+
the Rust oracle, reintroduced here.
|
|
1160
|
+
|
|
1161
|
+
UNCONDITIONAL -- called from `_collect` regardless of `--build`, a
|
|
1162
|
+
`board.yaml`, or an SDK checkout resolving. This is a HOST fact (an env
|
|
1163
|
+
var / a scanned install dir), and ADR 0021 Lane 1 P0a runs `tan doctor`
|
|
1164
|
+
as the very first command a customer runs, before anything project-shaped
|
|
1165
|
+
exists. A Yocto-only project still gets a real `fail` here -- that host
|
|
1166
|
+
genuinely has no Zephyr SDK -- not a skip for lacking a Zephyr core.
|
|
1167
|
+
|
|
1168
|
+
`env_dir` is the raw `ZEPHYR_SDK_INSTALL_DIR` value (or `None`), carried
|
|
1169
|
+
only to word the Fail detail correctly: "ZEPHYR_SDK_INSTALL_DIR unset" is
|
|
1170
|
+
true only when the variable really is unset. It used to be hardcoded even
|
|
1171
|
+
when the variable WAS set and simply named a directory with no working
|
|
1172
|
+
toolchain in it -- the exact stale-var case `_zephyr_sdk_detected` guards
|
|
1173
|
+
against -- so a customer who greps their own environment and finds it set
|
|
1174
|
+
disbelieved a diagnostic that was actually correct.
|
|
1175
|
+
|
|
1176
|
+
Paired with `seven_zip_check` on Windows (`_collect`, gated `os.name ==
|
|
1177
|
+
"nt"` and nothing else -- tan-cli#736 dropped the `!probe.zephyr_sdk` term
|
|
1178
|
+
the Rust sibling had, since the host WITH an SDK and no 7-Zip is precisely
|
|
1179
|
+
what the pairing is for; tan-cli#204 covers that check's SEVERITY, not its
|
|
1180
|
+
gate): the `west sdk install` this Fail's fix names cannot complete on
|
|
1181
|
+
native Windows without 7-Zip on PATH (`manual_install_windows` prose).
|
|
1182
|
+
"""
|
|
1183
|
+
if detected:
|
|
1184
|
+
return Check("zephyrSdk", "pass", "Zephyr SDK toolchain detected.", scope="host")
|
|
1185
|
+
where = (
|
|
1186
|
+
f"ZEPHYR_SDK_INSTALL_DIR=`{env_dir}` does not contain a working toolchain"
|
|
1187
|
+
if env_dir
|
|
1188
|
+
else "ZEPHYR_SDK_INSTALL_DIR unset"
|
|
1189
|
+
)
|
|
1190
|
+
# tan-cli#1520: the remedy is `tan bootstrap`, NOT the oracle's
|
|
1191
|
+
# `west sdk install ... -t arm-zephyr-eabi` (deliberate divergence): that
|
|
1192
|
+
# command's setup.sh downloads the toolchain archive with no integrity
|
|
1193
|
+
# check, bypassing the alp-sdk sha256 pin that #1501 made the boundary.
|
|
1194
|
+
# `tan bootstrap` verifies the archive and passes `--no-hosttools`, so the
|
|
1195
|
+
# tan-cli#424 `file(1)` prerequisite note no longer applies to this fix.
|
|
1196
|
+
return Check(
|
|
1197
|
+
"zephyrSdk",
|
|
1198
|
+
"fail",
|
|
1199
|
+
f"Zephyr SDK toolchain not detected ({where}) -- run `{ZEPHYR_SDK_BOOTSTRAP_FIX}`.",
|
|
1200
|
+
f"Install the Zephyr SDK toolchain (arm-zephyr-eabi, version "
|
|
1201
|
+
f"{ZEPHYR_SDK_INSTALL_VERSION}) with `{ZEPHYR_SDK_BOOTSTRAP_FIX}`, which "
|
|
1202
|
+
"verifies the downloaded archive against the alp-sdk pin; a hand-run "
|
|
1203
|
+
"`west sdk install` does not.",
|
|
1204
|
+
scope="host",
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _toolchain_store_dir(
|
|
1209
|
+
manifest: toolchain_provision.ToolchainManifest,
|
|
1210
|
+
) -> Path:
|
|
1211
|
+
root = toolchain_provision.resolve_toolchain_root(
|
|
1212
|
+
os.environ.get("ALP_TOOLCHAIN_ROOT"), str(_home_alp_dir())
|
|
1213
|
+
)
|
|
1214
|
+
return Path(root.path_str) / toolchain_provision.store_dir_name(manifest.version)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _toolchain_root_adopted() -> bool:
|
|
1218
|
+
"""`$ALP_TOOLCHAIN_ROOT` is set: tan did not necessarily create that root
|
|
1219
|
+
and `tan bootstrap` never deletes or repairs an unverified store
|
|
1220
|
+
directory in it (tan-cli#1498)."""
|
|
1221
|
+
return toolchain_provision.resolve_toolchain_root(
|
|
1222
|
+
os.environ.get("ALP_TOOLCHAIN_ROOT"), str(_home_alp_dir())
|
|
1223
|
+
).adopted
|
|
1224
|
+
|
|
1225
|
+
|
|
1226
|
+
def _adopted_store_remedy(store_dir: Path) -> str:
|
|
1227
|
+
return (
|
|
1228
|
+
f"ALP_TOOLCHAIN_ROOT is an adopted root, so `tan bootstrap` will not repair "
|
|
1229
|
+
f"it -- remove {store_dir} yourself (or unset ALP_TOOLCHAIN_ROOT), then run "
|
|
1230
|
+
f"`tan bootstrap`."
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
def _host_toolchain_matching_pin(
|
|
1235
|
+
manifest: toolchain_provision.ToolchainManifest,
|
|
1236
|
+
) -> Path | None:
|
|
1237
|
+
"""The already-installed host toolchain root (`_zephyr_sdk_detected_root`
|
|
1238
|
+
-- `ZEPHYR_SDK_INSTALL_DIR` or a scanned `zephyr-sdk*` directory, same
|
|
1239
|
+
precedence `zephyrSdk` above trusts) IF its own `sdk_version` file names
|
|
1240
|
+
the exact version `manifest` pins; else `None`.
|
|
1241
|
+
|
|
1242
|
+
`SDK_VERSION_FILE_RELPATH` is `west sdk install`'s own top-level marker
|
|
1243
|
+
(`toolchain_provision`'s own docstring: measured against a real extracted
|
|
1244
|
+
1.0.1 tree, exact byte content `"1.0.1\\n"`) -- read back and STRING-
|
|
1245
|
+
compared, matching `toolchain_phase`'s own post-install verification, so
|
|
1246
|
+
this reuses the identical trust boundary rather than a fresh directory-
|
|
1247
|
+
name guess. A missing or unreadable `sdk_version` -- an unusual layout
|
|
1248
|
+
`_zephyr_sdk_root_valid` still validated on compiler-binary presence
|
|
1249
|
+
alone -- is honestly "does not match", not a guessed pass.
|
|
1250
|
+
|
|
1251
|
+
**Never returns an entry inside tan's OWN ADR 0021 store for THIS pin
|
|
1252
|
+
(`_toolchain_store_dir(manifest)`) -- this adoption path is for a
|
|
1253
|
+
toolchain tan did NOT install (tan-cli#990's `~/zephyr-sdk-1.0.1` case),
|
|
1254
|
+
where a version-string match is the only signal available. Since
|
|
1255
|
+
tan-cli#1186 widened `_zephyr_sdk_scan_roots` to also cover that store
|
|
1256
|
+
(for `zephyrSdk`'s sake), `_zephyr_sdk_detected_root` can now return an
|
|
1257
|
+
entry living INSIDE it too -- and that entry is already governed by the
|
|
1258
|
+
stricter, digest-precise `stamp_matches_pin` check `toolchain_check` runs
|
|
1259
|
+
first. A store directory can carry a real compiler and a `sdk_version`
|
|
1260
|
+
matching the pin's nominal version while its stamp names a DIFFERENT
|
|
1261
|
+
digest (the pin moved without the version string changing, ADR 0021's
|
|
1262
|
+
own "stamped 1.0.1 against a moved pin" case) -- measured: without this
|
|
1263
|
+
guard that combination made this function return the store entry as a
|
|
1264
|
+
version-string match, turning the correct stamp-based `fail` a few lines
|
|
1265
|
+
below in `toolchain_check` into a `pass` it never reaches. Trusting the
|
|
1266
|
+
stamp alone for anything inside tan's own store, and never re-deriving a
|
|
1267
|
+
looser verdict for it here, keeps the two checks from disagreeing on the
|
|
1268
|
+
same directory.
|
|
1269
|
+
|
|
1270
|
+
Deliberately keyed on `_toolchain_store_dir(manifest)` -- the one
|
|
1271
|
+
per-version LEAF tan's own installs use -- rather than the broader
|
|
1272
|
+
`_toolchain_store_scan_root()` (the whole configured root, `~/.alp/
|
|
1273
|
+
toolchains` or `$ALP_TOOLCHAIN_ROOT` verbatim). `resolve_toolchain_root`
|
|
1274
|
+
takes an env override VERBATIM as the root, with no `/toolchains`
|
|
1275
|
+
suffix (`toolchain_provision.resolve_toolchain_root`), so ADR 0021's own
|
|
1276
|
+
documented bench/CI escape hatch -- `$ALP_TOOLCHAIN_ROOT` pointed at an
|
|
1277
|
+
ancestor like `$HOME` or `/opt` -- makes the store root coincide with a
|
|
1278
|
+
directory a hand-installed host toolchain also legitimately lives under.
|
|
1279
|
+
Excluding the whole root there would misread that adopted toolchain as
|
|
1280
|
+
"inside tan's own store" and refuse to adopt it, turning a
|
|
1281
|
+
previously-passing configuration into a `toolchain` FAIL; excluding only
|
|
1282
|
+
the specific `<root>/<store leaf for this manifest version>` directory
|
|
1283
|
+
keeps the guard narrow enough to still hold under that escape hatch.
|
|
1284
|
+
"""
|
|
1285
|
+
try:
|
|
1286
|
+
store = _toolchain_store_dir(manifest).resolve()
|
|
1287
|
+
except OSError:
|
|
1288
|
+
store = None
|
|
1289
|
+
for root in _zephyr_sdk_detected_roots():
|
|
1290
|
+
try:
|
|
1291
|
+
if store is not None and root.resolve().is_relative_to(store):
|
|
1292
|
+
continue
|
|
1293
|
+
except OSError:
|
|
1294
|
+
pass
|
|
1295
|
+
version_text = _read_text(root / toolchain_provision.SDK_VERSION_FILE_RELPATH)
|
|
1296
|
+
if version_text is not None and version_text.strip() == manifest.version:
|
|
1297
|
+
return root
|
|
1298
|
+
return None
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def toolchain_check(sdk_root: str | None) -> Check:
|
|
1302
|
+
"""`toolchain` -- STAMP-vs-PIN, never directory-exists (issue #474, ADR
|
|
1303
|
+
0021 Lane 1 P1's own words: "a stamped 1.0.1 store against a moved pin is
|
|
1304
|
+
a Fail with a fix, not 'a toolchain exists'"). Calls
|
|
1305
|
+
`toolchain_provision.stamp_matches_pin` -- the SAME function `tan
|
|
1306
|
+
bootstrap`'s own skip-if-already-installed step calls -- so this check
|
|
1307
|
+
and `bootstrap` cannot independently drift on what "still valid" means,
|
|
1308
|
+
the same `zephyr_python_floor`-is-imported-not-re-derived pattern this
|
|
1309
|
+
file already applies one layer up.
|
|
1310
|
+
|
|
1311
|
+
Distinct from `zephyrSdk` above, which answers "does ANY working
|
|
1312
|
+
toolchain exist on this host" unconditionally, with no SDK checkout
|
|
1313
|
+
needed (an env var or a scanned install dir). This one answers a
|
|
1314
|
+
narrower, PIN-specific question -- "does THIS checkout's pinned
|
|
1315
|
+
cross-toolchain version exist here, verified" -- so it needs `sdk_root`
|
|
1316
|
+
and reports `unknown` without one: there is no pin to check against.
|
|
1317
|
+
|
|
1318
|
+
`scope="project"` on every arm (never `"host"`, unlike `zephyrSdk`): a
|
|
1319
|
+
single check NAME may not carry two scopes (`tests/gates/
|
|
1320
|
+
test_doctor_check_scope.py`), and this one is inherently
|
|
1321
|
+
project-scoped -- it cannot answer at all without a resolved
|
|
1322
|
+
`sdk_root` to read `metadata/toolchains.json`'s PIN from.
|
|
1323
|
+
|
|
1324
|
+
**Has an adoption path (tan-cli#990 review, the tan-cli#299
|
|
1325
|
+
false-refusal class): an unstamped store is not the only way to satisfy
|
|
1326
|
+
this check.** Before either `fail` branch, `_host_toolchain_matches_pin`
|
|
1327
|
+
asks whether a toolchain THIS HOST ALREADY HAS -- found by the SAME
|
|
1328
|
+
`ZEPHYR_SDK_INSTALL_DIR`/scan `zephyrSdk` above already trusts -- reports
|
|
1329
|
+
the pinned version in ITS OWN `sdk_version` file. If so this is a `pass`,
|
|
1330
|
+
never a `fail`: a stamp is tan's own bookkeeping for what IT installed,
|
|
1331
|
+
not the only proof a working, correctly-versioned toolchain exists. Before
|
|
1332
|
+
this, a host with a hand-installed or pre-existing SDK at the exact pinned
|
|
1333
|
+
version -- `zephyrSdk` passing right above it -- got a `toolchain` FAIL
|
|
1334
|
+
whose only prescribed fix, `tan bootstrap`, downloads a SECOND ~1.9 GiB
|
|
1335
|
+
copy of a toolchain already on disk, and cannot even do that on an
|
|
1336
|
+
offline host or the README's own hand-`west sdk install` path (this
|
|
1337
|
+
check's one stamp-writing producer is `tan bootstrap` alone).
|
|
1338
|
+
"""
|
|
1339
|
+
if sdk_root is None:
|
|
1340
|
+
return Check(
|
|
1341
|
+
"toolchain",
|
|
1342
|
+
"unknown",
|
|
1343
|
+
"no alp-sdk checkout resolved -- cannot read metadata/toolchains.json to "
|
|
1344
|
+
"know which cross-toolchain version is pinned.",
|
|
1345
|
+
scope="project",
|
|
1346
|
+
)
|
|
1347
|
+
manifest_path = Path(sdk_root) / "metadata" / "toolchains.json"
|
|
1348
|
+
text = _read_text(manifest_path)
|
|
1349
|
+
if text is None:
|
|
1350
|
+
return Check(
|
|
1351
|
+
"toolchain",
|
|
1352
|
+
"unknown",
|
|
1353
|
+
f"{manifest_path} is missing or unreadable -- cannot determine the "
|
|
1354
|
+
f"pinned cross-toolchain version.",
|
|
1355
|
+
scope="project",
|
|
1356
|
+
)
|
|
1357
|
+
try:
|
|
1358
|
+
manifest = toolchain_provision.parse_toolchain_manifest(text)
|
|
1359
|
+
except toolchain_provision.ToolchainManifestError as err:
|
|
1360
|
+
return Check(
|
|
1361
|
+
"toolchain", "unknown", f"{manifest_path} is malformed: {err}", scope="project",
|
|
1362
|
+
)
|
|
1363
|
+
store_dir = _toolchain_store_dir(manifest)
|
|
1364
|
+
stamp_text = _read_text(store_dir / toolchain_provision.STAMP_FILENAME)
|
|
1365
|
+
stamp = toolchain_provision.parse_stamp(stamp_text) if stamp_text is not None else None
|
|
1366
|
+
adopted = _toolchain_root_adopted()
|
|
1367
|
+
if toolchain_provision.stamp_matches_pin(stamp, manifest):
|
|
1368
|
+
if not toolchain_provision.store_compiler_present(store_dir, is_windows=os.name == "nt"):
|
|
1369
|
+
if adopted:
|
|
1370
|
+
return Check(
|
|
1371
|
+
"toolchain",
|
|
1372
|
+
"fail",
|
|
1373
|
+
f"{store_dir} carries a verification stamp for {manifest.version} "
|
|
1374
|
+
f"but its arm-zephyr-eabi-gcc is gone -- "
|
|
1375
|
+
f"{_adopted_store_remedy(store_dir)}",
|
|
1376
|
+
scope="project",
|
|
1377
|
+
)
|
|
1378
|
+
return Check(
|
|
1379
|
+
"toolchain",
|
|
1380
|
+
"fail",
|
|
1381
|
+
f"{store_dir} carries a verification stamp for {manifest.version} but "
|
|
1382
|
+
f"its arm-zephyr-eabi-gcc is gone -- run `tan bootstrap` to repair it.",
|
|
1383
|
+
"tan bootstrap",
|
|
1384
|
+
scope="project",
|
|
1385
|
+
)
|
|
1386
|
+
return Check(
|
|
1387
|
+
"toolchain", "pass",
|
|
1388
|
+
f"arm-zephyr-eabi {manifest.version} installed at {store_dir}; version and "
|
|
1389
|
+
f"compiler were checked at install, but "
|
|
1390
|
+
f"{toolchain_provision.ARCHIVE_SHA256_NOTE}"
|
|
1391
|
+
+ ("." if stamp is not None and stamp.pin_checked else " (this install predates the check)."),
|
|
1392
|
+
scope="project",
|
|
1393
|
+
)
|
|
1394
|
+
host_root = _host_toolchain_matching_pin(manifest)
|
|
1395
|
+
if host_root is not None:
|
|
1396
|
+
return Check(
|
|
1397
|
+
"toolchain",
|
|
1398
|
+
"pass",
|
|
1399
|
+
f"arm-zephyr-eabi {manifest.version} detected at {host_root} -- not tan's "
|
|
1400
|
+
f"own store, but its `sdk_version` matches this checkout's pin, so this "
|
|
1401
|
+
f"host already has what `tan bootstrap` would otherwise download again. "
|
|
1402
|
+
f"That match is the `sdk_version` text file only: this install was never "
|
|
1403
|
+
f"compared with the pin (its archive may have been fetched unchecked); "
|
|
1404
|
+
f"{toolchain_provision.ARCHIVE_SHA256_NOTE}.",
|
|
1405
|
+
scope="project",
|
|
1406
|
+
)
|
|
1407
|
+
if stamp is not None:
|
|
1408
|
+
if adopted:
|
|
1409
|
+
return Check(
|
|
1410
|
+
"toolchain",
|
|
1411
|
+
"fail",
|
|
1412
|
+
f"{store_dir} carries a verification stamp for a different pin (this "
|
|
1413
|
+
f"checkout now pins {manifest.version}) -- "
|
|
1414
|
+
f"{_adopted_store_remedy(store_dir)}",
|
|
1415
|
+
scope="project",
|
|
1416
|
+
)
|
|
1417
|
+
return Check(
|
|
1418
|
+
"toolchain",
|
|
1419
|
+
"fail",
|
|
1420
|
+
f"{store_dir} carries a verification stamp for a different pin (this "
|
|
1421
|
+
f"checkout now pins {manifest.version}) -- run `tan bootstrap` to "
|
|
1422
|
+
f"acquire the current pin.",
|
|
1423
|
+
"tan bootstrap",
|
|
1424
|
+
scope="project",
|
|
1425
|
+
)
|
|
1426
|
+
if adopted and store_dir.is_dir():
|
|
1427
|
+
return Check(
|
|
1428
|
+
"toolchain",
|
|
1429
|
+
"fail",
|
|
1430
|
+
f"no verified arm-zephyr-eabi {manifest.version} toolchain at {store_dir} "
|
|
1431
|
+
f"-- {_adopted_store_remedy(store_dir)}",
|
|
1432
|
+
scope="project",
|
|
1433
|
+
)
|
|
1434
|
+
return Check(
|
|
1435
|
+
"toolchain",
|
|
1436
|
+
"fail",
|
|
1437
|
+
f"no verified arm-zephyr-eabi {manifest.version} toolchain at {store_dir} -- "
|
|
1438
|
+
f"run `tan bootstrap` to acquire it (ADR 0021 Lane 1 P1).",
|
|
1439
|
+
"tan bootstrap",
|
|
1440
|
+
scope="project",
|
|
1441
|
+
)
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def devicetree_lint_check(resolution: devicetree_lint.DtcResolution) -> Check:
|
|
1445
|
+
"""`devicetreeLint` -- will Zephyr's diagnostics-only `dtc` pass actually
|
|
1446
|
+
run for a build on this host (tan-cli#1192)?
|
|
1447
|
+
|
|
1448
|
+
See `tan.core.devicetree_lint` for the two CMake mechanisms this mirrors
|
|
1449
|
+
(`find_program(DTC dtc)` over the SDK's `hosttools` prefix then `PATH`,
|
|
1450
|
+
then `find_package(Dtc 1.4.6)`'s silent `DTC-NOTFOUND` reset) and for the
|
|
1451
|
+
tan-cli#1176/#1178 premise change that made the question worth asking.
|
|
1452
|
+
|
|
1453
|
+
**This partially reverses this module's own recorded decision that
|
|
1454
|
+
`dtc`/`gperf` were deliberately NOT ported from the frozen Rust oracle's
|
|
1455
|
+
`--build` vocabulary** (see the module docstring). That decision was right
|
|
1456
|
+
when it was made and its premise is gone: `dtc` used to arrive with the
|
|
1457
|
+
Zephyr SDK on every install, so a `dtc` probe could only restate what
|
|
1458
|
+
`zephyrSdk` already reported. Since tan-cli#1178 `tan bootstrap` passes
|
|
1459
|
+
`--no-hosttools`, the SDK ships no `dtc`, and the lint pass stopped
|
|
1460
|
+
running with nothing anywhere saying so. `gperf` stays unported; only
|
|
1461
|
+
`dtc` is reopened, and only as this question.
|
|
1462
|
+
|
|
1463
|
+
**The severity, which is the whole difficulty.** A naive "is `dtc` on
|
|
1464
|
+
PATH" `warn` would fire on EVERY correctly-bootstrapped host, which is the
|
|
1465
|
+
anti-pattern `west_check`'s own docstring records ("a warning that fires
|
|
1466
|
+
on every correct install trains users to ignore warnings", tan-cli#299).
|
|
1467
|
+
So the no-`dtc` arm is a `pass`: it is the ordinary, supported, documented
|
|
1468
|
+
state, the build genuinely succeeds, and `tan bootstrap` is not a remedy
|
|
1469
|
+
for it -- bootstrap is what caused it, on purpose. The row still SAYS the
|
|
1470
|
+
lint is not running, which is the visibility tan-cli#1192 asks for, but it
|
|
1471
|
+
raises no `issues[]` entry, contributes no `nextSteps` line, lands in
|
|
1472
|
+
`summary.pass`, and cannot move the exit code.
|
|
1473
|
+
|
|
1474
|
+
The one `warn` arm is the host where somebody INSTALLED a `dtc` and does
|
|
1475
|
+
not get the lint anyway, because `FindDtc.cmake` judged it unusable and
|
|
1476
|
+
reset it to `DTC-NOTFOUND` without printing anything. That is a real,
|
|
1477
|
+
actionable divergence between what an operator did and what they got, it
|
|
1478
|
+
is unreachable on a host provisioned per this repo's own onramp, and its
|
|
1479
|
+
fix is concrete. `warn`, never `fail`: `exit_code_for` reserves exit 4 for
|
|
1480
|
+
a host that cannot build, and this one builds fine -- it just builds
|
|
1481
|
+
without a class of diagnostics.
|
|
1482
|
+
|
|
1483
|
+
NOT harvested by `tan support-bundle`: that command reports only the five
|
|
1484
|
+
HOST checks `support_bundle_cmd._HOST_CHECK_ORDER` names, built by
|
|
1485
|
+
`host_environment_checks` (which this is deliberately not added to), so
|
|
1486
|
+
this check reaches neither that command's report nor its verdict. That is
|
|
1487
|
+
the right call for the same reason `_demote_long_paths_fail` exists --
|
|
1488
|
+
support-bundle's acceptance bar is matching the frozen oracle's axes, and
|
|
1489
|
+
the oracle has no devicetree-lint axis at all.
|
|
1490
|
+
|
|
1491
|
+
`scope="host"` on every arm: the subject is this machine's tooling (an SDK
|
|
1492
|
+
install and `PATH`), identical for every project built on it, which is
|
|
1493
|
+
`tan.core.doctor_scope`'s own test.
|
|
1494
|
+
"""
|
|
1495
|
+
floor = devicetree_lint.format_version(devicetree_lint.DTC_MIN_VERSION)
|
|
1496
|
+
lint_flags = "`-E unit_address_vs_reg` / `-Wunique_unit_address_if_enabled`"
|
|
1497
|
+
if devicetree_lint.lint_will_run(resolution) and resolution.version is not None:
|
|
1498
|
+
where = (
|
|
1499
|
+
"the Zephyr SDK's own hosttools bundle, which `host-tools.cmake` puts on "
|
|
1500
|
+
"`CMAKE_PREFIX_PATH`"
|
|
1501
|
+
if resolution.origin == devicetree_lint.ORIGIN_HOSTTOOLS
|
|
1502
|
+
else "this host's PATH"
|
|
1503
|
+
)
|
|
1504
|
+
return Check(
|
|
1505
|
+
"devicetreeLint",
|
|
1506
|
+
"pass",
|
|
1507
|
+
f"dtc {devicetree_lint.format_version(resolution.version)} at "
|
|
1508
|
+
f"{resolution.path} ({where}) -- Zephyr's devicetree lint pass "
|
|
1509
|
+
f"({lint_flags}, `dts.cmake`'s `if(DTC)` block) runs for builds here.",
|
|
1510
|
+
scope="host",
|
|
1511
|
+
)
|
|
1512
|
+
if resolution.path is not None:
|
|
1513
|
+
answered = (
|
|
1514
|
+
f"reports {devicetree_lint.format_version(resolution.version)}, below the "
|
|
1515
|
+
f"{floor} floor"
|
|
1516
|
+
if resolution.version is not None
|
|
1517
|
+
else "did not answer `dtc --version` in a form `FindDtc.cmake` can parse"
|
|
1518
|
+
)
|
|
1519
|
+
return Check(
|
|
1520
|
+
"devicetreeLint",
|
|
1521
|
+
"warn",
|
|
1522
|
+
f"`dtc` is installed at {resolution.path} but {answered}, so Zephyr's "
|
|
1523
|
+
f"`find_package(Dtc {floor})` rejects it and `FindDtc.cmake` resets DTC to "
|
|
1524
|
+
f"`DTC-NOTFOUND` -- silently. The devicetree lint pass ({lint_flags}) does "
|
|
1525
|
+
f"not run, exactly as if no dtc were installed, and nothing in the build "
|
|
1526
|
+
f"output says so.",
|
|
1527
|
+
f"Install dtc {floor} or newer (Debian/Ubuntu `device-tree-compiler`, "
|
|
1528
|
+
f"Homebrew `dtc`) ahead of {resolution.path} on PATH, or drop this one so "
|
|
1529
|
+
f"the report stops claiming a lint that is not happening.",
|
|
1530
|
+
scope="host",
|
|
1531
|
+
)
|
|
1532
|
+
return Check(
|
|
1533
|
+
"devicetreeLint",
|
|
1534
|
+
"pass",
|
|
1535
|
+
f"no `dtc` in CMake's reach (neither a Zephyr SDK `hosttools/` copy nor one on "
|
|
1536
|
+
f"PATH), so Zephyr's devicetree lint pass ({lint_flags}) is SKIPPED -- "
|
|
1537
|
+
f"`dts.cmake` guards it with `if(DTC)` and treats dtc as optional. Builds "
|
|
1538
|
+
f"succeed and produce a real `zephyr.elf`; only those diagnostics are absent. "
|
|
1539
|
+
f"This is the expected state after `tan bootstrap`, which acquires the Zephyr "
|
|
1540
|
+
f"SDK with `--no-hosttools` (tan-cli#1178) and so installs no dtc. Install a "
|
|
1541
|
+
f"system dtc {floor} or newer if you want the lint back.",
|
|
1542
|
+
scope="host",
|
|
1543
|
+
)
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def seven_zip_check(found: bool) -> Check:
|
|
1547
|
+
"""`sevenZip` -- Windows-only, unconditionally (tan-cli#736 dropped the
|
|
1548
|
+
`zephyrSdk`-is-failing half). Ports the Rust oracle's sibling (`crate::
|
|
1549
|
+
build_readiness`, tan-cli#204): `west sdk install`, the remedy
|
|
1550
|
+
`zephyr_sdk_check` names, extracts the `.7z` toolchain archive by
|
|
1551
|
+
delegating to `patoolib`, which shells out to one of `SEVEN_ZIP_PROGRAMS`
|
|
1552
|
+
and has no pure-Python fallback -- documented in this repo's own
|
|
1553
|
+
`tan.core.bootstrap` (`manual_install_windows` prose) but, until this
|
|
1554
|
+
check, reaching no JSON consumer, so `alp-sdk-vscode` had no way to
|
|
1555
|
+
surface it and a customer who followed the `zephyrSdk` fix hint alone hit
|
|
1556
|
+
a patoolib error naming no Alp surface and no mention of 7-Zip.
|
|
1557
|
+
|
|
1558
|
+
`Warn`, not `Fail`, mirroring the oracle: missing 7-Zip blocks the
|
|
1559
|
+
REMEDY, not the build itself -- `zephyrSdk` is the `Fail` that stops
|
|
1560
|
+
things. That reasoning is unchanged.
|
|
1561
|
+
|
|
1562
|
+
What DID change (tan-cli#736) is the gate this check used to ride: it
|
|
1563
|
+
ran only alongside a FAILING `zephyrSdk`, on the premise that "a host
|
|
1564
|
+
that already has the SDK never reaches this". That premise does not
|
|
1565
|
+
hold -- "already has the SDK" is not "will never run `west sdk
|
|
1566
|
+
install` again". Adding a second architecture's toolchain, or moving to
|
|
1567
|
+
a newer SDK, is an ordinary operation; `zephyrSdk` passes throughout,
|
|
1568
|
+
and the check never fired. Measured on Windows with `7z` stripped from
|
|
1569
|
+
PATH and the SDK present: `overall ok: True`, and
|
|
1570
|
+
`any check mentioning 7z: []` -- a green verdict on a host that cannot
|
|
1571
|
+
complete `west sdk install`. It now runs unconditionally on Windows.
|
|
1572
|
+
"""
|
|
1573
|
+
if found:
|
|
1574
|
+
return Check(
|
|
1575
|
+
"sevenZip",
|
|
1576
|
+
"pass",
|
|
1577
|
+
"7-Zip is available -- `west sdk install` can extract the toolchain.",
|
|
1578
|
+
scope="host",
|
|
1579
|
+
)
|
|
1580
|
+
programs = ", ".join(SEVEN_ZIP_PROGRAMS)
|
|
1581
|
+
return Check(
|
|
1582
|
+
"sevenZip",
|
|
1583
|
+
"warn",
|
|
1584
|
+
f"No 7-Zip on PATH (looked for {programs}) -- `west sdk install` extracts "
|
|
1585
|
+
"the toolchain with patoolib, which shells out to one of these and has no "
|
|
1586
|
+
"pure-Python fallback, so it will fail on native Windows. Install it with "
|
|
1587
|
+
f"`{SEVEN_ZIP_INSTALL_COMMAND}`.",
|
|
1588
|
+
f"Install 7-Zip before running `west sdk install`: `{SEVEN_ZIP_INSTALL_COMMAND}`.",
|
|
1589
|
+
scope="host",
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def zephyr_workspace_check(workspace_dir: str, version_text: str | None) -> Check:
|
|
1594
|
+
"""`zephyrWorkspace` -- unconditional now, not `--build`-only
|
|
1595
|
+
(tan-cli#290): does the RESOLVED workspace's `zephyr/` subtree actually
|
|
1596
|
+
look like a Zephyr checkout at all?
|
|
1597
|
+
|
|
1598
|
+
`workspace_dir`/`version_text` are the SAME
|
|
1599
|
+
`tan.core.venv.west_workspace_dir`-resolved facts `workspace`/
|
|
1600
|
+
`zephyrVersion` above already compute -- not a second, independent
|
|
1601
|
+
`$ZEPHYR_BASE` env-var read, which was tan-cli#294's own complaint about
|
|
1602
|
+
this check ("probes an env var, not the resolved topdir"). Callers only
|
|
1603
|
+
reach this once a workspace has actually resolved: `workspace` above
|
|
1604
|
+
already fails outright on a totally-absent one, and re-warning that same
|
|
1605
|
+
absence here under a second name would be exactly the one-fact-twice
|
|
1606
|
+
duplication this file's `boardYaml` handling (mirroring the Rust oracle)
|
|
1607
|
+
already refuses to do -- so there is no "unresolved" branch here at all.
|
|
1608
|
+
|
|
1609
|
+
**No Fail branch (tan-cli#295 review, reversing tan-cli#290's own
|
|
1610
|
+
addition of one).** A version-mismatch Fail was added to mirror Rust's
|
|
1611
|
+
`crates/tan-core/src/preflight.rs:118-145` (tan-cli#98/#159, the
|
|
1612
|
+
alp-sdk#855 v4.4.0->v4.4.1 incident, where a drifted checkout reported
|
|
1613
|
+
`11 passed, 6 warnings, 0 failed` and the very next build broke) -- but
|
|
1614
|
+
`zephyr_version_preflight_check` above already reports that identical
|
|
1615
|
+
fact, from these identical two inputs (`workspace_version`/`sdk_pin`), at
|
|
1616
|
+
Fail severity. This check's would-be Fail condition was a strict SUBSET
|
|
1617
|
+
of that one, so it could never fire without `zephyrVersion` having
|
|
1618
|
+
already reported it under a different code: measured on a drifted host,
|
|
1619
|
+
`summary.fail` came out 5 instead of 4, both `doctor.zephyrVersion` and
|
|
1620
|
+
`doctor.zephyrWorkspace` present, and two `nextSteps` strings for the one
|
|
1621
|
+
`tan bootstrap` remedy. Removed rather than kept "in step" with it --
|
|
1622
|
+
Rust's own `crates/tan-cli/src/commands/doctor.rs` drops its comparable
|
|
1623
|
+
`boardYaml` duplicate for the identical reason ("emitting both would
|
|
1624
|
+
report one fact twice"), and `grep -rn "zephyrWorkspace" crates/` is
|
|
1625
|
+
empty: there is no Rust oracle row here for a version-mismatch Fail to
|
|
1626
|
+
stay parallel with.
|
|
1627
|
+
|
|
1628
|
+
An unreadable `zephyr/VERSION` stays `Warn`: neither the Rust oracle nor
|
|
1629
|
+
`zephyr_version_preflight_check` above (which silently SKIPS rather than
|
|
1630
|
+
fails when the version is unknown -- "don't nag when this cannot
|
|
1631
|
+
actually be verified") treats this as more than that, and a resolved
|
|
1632
|
+
`.west` workspace mid-`west update` -- `zephyr/` not yet cloned -- is a
|
|
1633
|
+
legitimate, working-in-progress host state, not a proven blocker. This is
|
|
1634
|
+
the one fact `zephyrVersion` cannot see at all (it skips outright), so it
|
|
1635
|
+
is this check's whole remaining reason to exist.
|
|
1636
|
+
"""
|
|
1637
|
+
if version_text is None:
|
|
1638
|
+
return Check(
|
|
1639
|
+
"zephyrWorkspace",
|
|
1640
|
+
"warn",
|
|
1641
|
+
f"workspace at `{workspace_dir}` does not look like a Zephyr checkout "
|
|
1642
|
+
f"(no readable zephyr/VERSION file).",
|
|
1643
|
+
"Run `tan bootstrap`, or point the workspace at a real Zephyr checkout.",
|
|
1644
|
+
scope="project",
|
|
1645
|
+
)
|
|
1646
|
+
return Check(
|
|
1647
|
+
"zephyrWorkspace", "pass", f"Zephyr {version_text} at `{workspace_dir}`.",
|
|
1648
|
+
scope="project",
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def jlink_available(app_dir: str, sdk_root: str | None) -> bool:
|
|
1653
|
+
"""Whether `tan flash` would find a J-Link tool: the SAME test it applies
|
|
1654
|
+
(`flash_cmd._tool_available` -- PATH or the workspace venv)."""
|
|
1655
|
+
try:
|
|
1656
|
+
venv_bin = venv_bin_dir(app_dir, sdk_root)
|
|
1657
|
+
return any(_tool_available(n, venv_bin) for n in ("JLinkExe", "JLink"))
|
|
1658
|
+
except Exception: # noqa: BLE001
|
|
1659
|
+
return False
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def setools_check(
|
|
1663
|
+
setools_dir: str | None,
|
|
1664
|
+
se_uart: str | None,
|
|
1665
|
+
is_linux: bool,
|
|
1666
|
+
flash_methods: frozenset[str] | None = None,
|
|
1667
|
+
jlink_found: bool | None = None,
|
|
1668
|
+
setools_source: str = "$SETOOLS_DIR",
|
|
1669
|
+
) -> Check:
|
|
1670
|
+
"""`setools` -- can this host flash an Alif AEN part's MRAM at all?
|
|
1671
|
+
|
|
1672
|
+
Nothing else in either doctor asks. `scripts/west_commands/runners/
|
|
1673
|
+
alif_flash.py` raises a bare `RuntimeError` for each of these the moment a
|
|
1674
|
+
customer runs `west flash`, so the first time they learn is at the bench.
|
|
1675
|
+
|
|
1676
|
+
WARN, not FAIL: this is one flow, on one SoM family. A customer building for
|
|
1677
|
+
a V2N or native_sim never touches it, and a `fail` here would exit 4 on a
|
|
1678
|
+
perfectly healthy host. `unknown` off Linux -- `alif_flash.py` hard-codes
|
|
1679
|
+
`app-release-exec-linux`, so there is no verdict to give a native
|
|
1680
|
+
Windows/macOS host, and `unknown` is counted in no summary bucket.
|
|
1681
|
+
|
|
1682
|
+
**No longer probes any interpreter for `fdt` (tan-cli#641).** It used to,
|
|
1683
|
+
most recently against the workspace venv's own Python (tan-cli#488 defect
|
|
1684
|
+
6). Measured on real AEN silicon: `fdt` was genuinely unimportable in the
|
|
1685
|
+
workspace venv while `app-gen-toc` -- which `tan.core.setools.sign_slot0`
|
|
1686
|
+
spawns as its own subprocess, never as a Python import under any
|
|
1687
|
+
interpreter this command could probe, and which SETOOLS ships its own
|
|
1688
|
+
dependencies for regardless -- signed successfully, and the resulting MRAM
|
|
1689
|
+
write persisted a cold power cycle. Probing one interpreter's `sys.path`
|
|
1690
|
+
was never going to answer whether a flash that spawns a separate,
|
|
1691
|
+
license-gated binary will succeed, and the false alarm it produced here
|
|
1692
|
+
trains an operator to stop trusting doctor on the one question they ask it
|
|
1693
|
+
before a write.
|
|
1694
|
+
|
|
1695
|
+
**Method-aware (tan-cli#1323).** `$SE_UART` belongs to Flow A
|
|
1696
|
+
(`zephyr_west_flash` -> the `alif_flash` runner) ONLY. A planner-emitted
|
|
1697
|
+
AEN manifest dispatches Flow D (`alif_mram_jlink`): SETOOLS signs the ATOC
|
|
1698
|
+
(`app-gen-toc`) and J-Link writes it over SWD, no SE-UART. `flash_methods`
|
|
1699
|
+
is the set `tan flash` would dispatch for the project's built manifest
|
|
1700
|
+
(`tan.core.doctor_setools.project_flash_methods`, which calls
|
|
1701
|
+
`select_flash_method` itself); `None` means no project/manifest is in
|
|
1702
|
+
scope, and the verdict is then phrased per method rather than asserting
|
|
1703
|
+
one. `jlink_found` is whether a J-Link tool is on PATH (Flow D only).
|
|
1704
|
+
"""
|
|
1705
|
+
flow_d = flash_methods is not None and FLOW_D_METHOD in flash_methods
|
|
1706
|
+
flow_a = flash_methods is not None and FLOW_A_METHOD in flash_methods
|
|
1707
|
+
if flash_methods is not None and not (flow_a or flow_d):
|
|
1708
|
+
return Check(
|
|
1709
|
+
"setools",
|
|
1710
|
+
"unknown",
|
|
1711
|
+
"this project's slices flash via "
|
|
1712
|
+
f"{', '.join(f'`{m}`' for m in sorted(flash_methods)) or 'no method'}, "
|
|
1713
|
+
"which does not use SETOOLS -- nothing to check here.",
|
|
1714
|
+
scope="host",
|
|
1715
|
+
)
|
|
1716
|
+
if flow_a and not flow_d and not is_linux and not setools_dir and not se_uart:
|
|
1717
|
+
return Check(
|
|
1718
|
+
"setools",
|
|
1719
|
+
"unknown",
|
|
1720
|
+
"AEN MRAM flashing over the SE-UART is Linux-only in this tree: "
|
|
1721
|
+
"scripts/west_commands/runners/alif_flash.py hard-codes "
|
|
1722
|
+
f"`app-release-exec-linux`, so its bundle is `{SETOOLS_BUNDLE}`. "
|
|
1723
|
+
"Nothing to check on this host for THAT path -- run it from "
|
|
1724
|
+
"WSL2/Linux (Windows hosts pass the SE-UART through with usbipd). "
|
|
1725
|
+
"Flow D below is a different matter: it signs on Windows with the "
|
|
1726
|
+
"WINDOWS SETOOLS build, which is a different bundle from the "
|
|
1727
|
+
"`-linux` one named above -- point `--setools-dir` at it (measured: "
|
|
1728
|
+
"`app-gen-toc.exe` under a Windows `app-release-exec`, "
|
|
1729
|
+
"SETOOLS_version_SE_FW_1.110.00_DEV, signed an ATOC the part "
|
|
1730
|
+
"booted from).",
|
|
1731
|
+
scope="host",
|
|
1732
|
+
)
|
|
1733
|
+
|
|
1734
|
+
status, detail, fix = setools_verdict(
|
|
1735
|
+
setools_dir,
|
|
1736
|
+
setools_source,
|
|
1737
|
+
se_uart,
|
|
1738
|
+
flash_methods,
|
|
1739
|
+
jlink_found,
|
|
1740
|
+
SETOOLS_BUNDLE,
|
|
1741
|
+
SETOOLS_EXECUTABLES,
|
|
1742
|
+
is_linux,
|
|
1743
|
+
)
|
|
1744
|
+
return Check("setools", status, detail, fix, scope="host")
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def jlink_banner(jlink_exe: str, timeout: int = PROBE_TIMEOUT_S) -> str | None:
|
|
1748
|
+
"""The version banner JLinkExe prints, unprompted, at the top of EVERY
|
|
1749
|
+
invocation -- `SEGGER J-Link Commander V9.50 (Compiled ...)` / `DLL
|
|
1750
|
+
version V9.50` -- before it ever tries to reach a probe or a target.
|
|
1751
|
+
|
|
1752
|
+
**Not `[jlink_exe, "-?"]` (tan-cli#641).** JLinkExe has no such flag:
|
|
1753
|
+
`JLinkExe -?` answers `Unknown command line option -?.` and exits
|
|
1754
|
+
non-zero, so the old probe never once reached the banner it was trying to
|
|
1755
|
+
read -- measured on real AEN silicon, this was the whole reason a J-Link
|
|
1756
|
+
tool that answers `V9.50` unprompted on every run still reported "version
|
|
1757
|
+
could not be read".
|
|
1758
|
+
|
|
1759
|
+
Feeds `exit\\n` on stdin so Commander quits at its own interactive prompt
|
|
1760
|
+
rather than sitting there waiting for input or a probe that may not be
|
|
1761
|
+
plugged in; `timeout` still bounds it regardless. Unlike `probe()`, this
|
|
1762
|
+
reads stdout REGARDLESS of the exit code -- the banner is already printed
|
|
1763
|
+
by the time Commander decides how it exits (e.g. because no emulator is
|
|
1764
|
+
attached), and that text must not be discarded along with a non-zero
|
|
1765
|
+
status that says nothing about whether the banner itself was readable.
|
|
1766
|
+
|
|
1767
|
+
Commander-only contract: this assumes `jlink_exe` is `JLinkExe`/`JLink`,
|
|
1768
|
+
which quit on the `exit\\n` fed to them. `JLinkGDBServerCL` does not read
|
|
1769
|
+
stdin at all -- it waits for a GDB connection and holds its port for the
|
|
1770
|
+
full `timeout` regardless of what is piped in. The caller (`_collect`)
|
|
1771
|
+
must not spawn this against that binary; see its comment there.
|
|
1772
|
+
|
|
1773
|
+
`-NoGui 1` matches every other J-Link spawn in this repo
|
|
1774
|
+
(`flash_plan.py`, `flash_cmd.py`) -- without it a GUI-capable install can
|
|
1775
|
+
raise a modal dialog (firmware-update / device-select) that `exit\\n` on
|
|
1776
|
+
stdin does not dismiss, blocking for the full `timeout` regardless.
|
|
1777
|
+
"""
|
|
1778
|
+
try:
|
|
1779
|
+
completed = subprocess.run(
|
|
1780
|
+
[jlink_exe, "-NoGui", "1"],
|
|
1781
|
+
input="exit\n",
|
|
1782
|
+
capture_output=True,
|
|
1783
|
+
text=True,
|
|
1784
|
+
encoding="utf-8",
|
|
1785
|
+
errors="replace",
|
|
1786
|
+
timeout=timeout,
|
|
1787
|
+
env=spawn_env(),
|
|
1788
|
+
check=False,
|
|
1789
|
+
)
|
|
1790
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
1791
|
+
return None
|
|
1792
|
+
return completed.stdout or None
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def jlink_check(
|
|
1796
|
+
found: str | None,
|
|
1797
|
+
version: tuple[int, int] | None,
|
|
1798
|
+
device: str = JLINK_AEN_DEVICE,
|
|
1799
|
+
device_source: str | None = None,
|
|
1800
|
+
) -> Check:
|
|
1801
|
+
"""`jlink` -- Flow D, the day-to-day burn path (J-Link direct MRAM flash over
|
|
1802
|
+
SWD, ~0.16 s, no SE-UART).
|
|
1803
|
+
|
|
1804
|
+
Three facts a presence check alone would hide, so all three travel in the
|
|
1805
|
+
message even when the binary is there: the loader is built into the J-Link
|
|
1806
|
+
DLL from V9.46 (nothing separate to install, and nothing at all below it),
|
|
1807
|
+
it is unlocked ONLY by the part-number device profile -- the generic
|
|
1808
|
+
`Cortex-M55` connects fine and has no MRAM loader, so a burn against it
|
|
1809
|
+
silently is not one. Both are unprobeable from the host, which is exactly
|
|
1810
|
+
why they must be said.
|
|
1811
|
+
|
|
1812
|
+
tan-cli#739: a third "fact" used to travel with them -- that the probe
|
|
1813
|
+
needs matched J-Link V13 firmware or the part-number device will not
|
|
1814
|
+
connect. Measured on the AEN EVK, that is false. A probe reporting
|
|
1815
|
+
`Firmware: J-Link V11 compiled Apr 1 2025 10:02:30` / `Hardware version:
|
|
1816
|
+
V11.00` connected WITH the part-number profile and programmed MRAM
|
|
1817
|
+
repeatedly, including a 96 KiB loadbin + verifybin at 0x80560000 that
|
|
1818
|
+
byte-verified (alp-sdk#1380 SS1). The claim sent customers with a working
|
|
1819
|
+
probe to a firmware update they did not need, and read as "your setup is
|
|
1820
|
+
unsupported" on a setup that demonstrably works.
|
|
1821
|
+
|
|
1822
|
+
It is NOT restated as "V11+". Where the real floor sits is unknown -- no
|
|
1823
|
+
probe old enough to fail has been tested -- and swapping one unmeasured
|
|
1824
|
+
minimum for another is the same defect wearing a different number. The
|
|
1825
|
+
firmware clause is dropped entirely; the DLL floor and the device profile
|
|
1826
|
+
are the two requirements that are actually established.
|
|
1827
|
+
|
|
1828
|
+
`device` defaults to `JLINK_AEN_DEVICE` so every existing call site keeps
|
|
1829
|
+
working; `_collect` passes the metadata-resolved value from
|
|
1830
|
+
`jlink_flash_device` instead, when an SDK checkout resolved one.
|
|
1831
|
+
|
|
1832
|
+
`device_source` (also from `jlink_flash_device`) is surfaced into the
|
|
1833
|
+
detail text when given, so the same `device` string is not byte-identical
|
|
1834
|
+
whether it came from a resolved SDK checkout or tan's built-in fallback --
|
|
1835
|
+
otherwise a user on a host where the SDK did not resolve has no way to
|
|
1836
|
+
tell which one they are looking at.
|
|
1837
|
+
"""
|
|
1838
|
+
requirements = (
|
|
1839
|
+
f"Flow D needs the `{device}` part-number device profile (NOT the "
|
|
1840
|
+
f"generic `Cortex-M55`, which has no MRAM loader) and a J-Link DLL "
|
|
1841
|
+
f"V{_fmt(JLINK_MIN_DLL)}+."
|
|
1842
|
+
)
|
|
1843
|
+
if device_source is not None:
|
|
1844
|
+
requirements += f" Device profile resolved from: {device_source}."
|
|
1845
|
+
if found is None:
|
|
1846
|
+
return Check(
|
|
1847
|
+
"jlink",
|
|
1848
|
+
"warn",
|
|
1849
|
+
"SEGGER J-Link tools are not on PATH (optional -- needed for Flow D "
|
|
1850
|
+
"MRAM flash and SWD debug, not for native_sim or SE-UART flashing). "
|
|
1851
|
+
+ requirements,
|
|
1852
|
+
"Install the SEGGER J-Link Software & Documentation Pack "
|
|
1853
|
+
f"(V{_fmt(JLINK_MIN_DLL)} or newer).",
|
|
1854
|
+
scope="host",
|
|
1855
|
+
)
|
|
1856
|
+
if version is None:
|
|
1857
|
+
return Check(
|
|
1858
|
+
"jlink",
|
|
1859
|
+
"warn",
|
|
1860
|
+
f"J-Link tools found at {found} but their version could not be read, so "
|
|
1861
|
+
f"the Flow D MRAM loader could not be confirmed. " + requirements,
|
|
1862
|
+
"Run `JLinkExe` by hand (it prints the banner unprompted, then type "
|
|
1863
|
+
"`exit`) and confirm it reports "
|
|
1864
|
+
f"V{_fmt(JLINK_MIN_DLL)} or newer.",
|
|
1865
|
+
scope="host",
|
|
1866
|
+
)
|
|
1867
|
+
if version < JLINK_MIN_DLL:
|
|
1868
|
+
return Check(
|
|
1869
|
+
"jlink",
|
|
1870
|
+
"warn",
|
|
1871
|
+
f"J-Link V{_fmt(version)} ({found}) predates V{_fmt(JLINK_MIN_DLL)}, which "
|
|
1872
|
+
f"is where Alif's MRAM flash loader became built in -- Flow D has nothing "
|
|
1873
|
+
f"to program MRAM with on this DLL. " + requirements,
|
|
1874
|
+
f"Upgrade the SEGGER J-Link pack to V{_fmt(JLINK_MIN_DLL)}+.",
|
|
1875
|
+
scope="host",
|
|
1876
|
+
)
|
|
1877
|
+
return Check(
|
|
1878
|
+
"jlink", "pass", f"J-Link V{_fmt(version)} ({found}). " + requirements,
|
|
1879
|
+
scope="host",
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
# ---------------------------------------------------------------------------
|
|
1884
|
+
# Host-environment checks (tan-cli#294 finding 1, reintroducing tan-cli#70).
|
|
1885
|
+
#
|
|
1886
|
+
# `zephyr_sdk_check` above only answers "is a Zephyr SDK installed HERE" --
|
|
1887
|
+
# never "CAN one be installed on this machine at all". A Windows-on-ARM or
|
|
1888
|
+
# Intel-Mac host is served by neither a native Zephyr SDK build nor (on
|
|
1889
|
+
# macOS) a WSL2 fallback, and `zephyrSdkAvailableForHost` below is the ONLY
|
|
1890
|
+
# check that says so; `zephyrSdk`'s Fail just points at a `west sdk install`
|
|
1891
|
+
# that can never complete there. Unconditional, like `zephyr_sdk_check`: a
|
|
1892
|
+
# HOST fact needing no board.yaml/workspace/SDK, so it runs on plain
|
|
1893
|
+
# `tan doctor` (ADR 0021 Lane 1 P0a runs that BEFORE anything project-shaped
|
|
1894
|
+
# exists).
|
|
1895
|
+
# ---------------------------------------------------------------------------
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
def zephyr_sdk_host_check(host_os: str, arch: str) -> Check:
|
|
1899
|
+
"""`zephyrSdkAvailableForHost` -- mirrors
|
|
1900
|
+
`tan_core::host_env::zephyr_sdk_host_check` byte-for-byte, including the
|
|
1901
|
+
two DIFFERENT remedies for the two unserved hosts: a Windows-on-ARM host
|
|
1902
|
+
has a first-class route (WSL2, which reports as the served
|
|
1903
|
+
`linux-aarch64`), a macOS host does not (Rosetta translates x86_64 FOR
|
|
1904
|
+
Apple silicon, not the reverse, and there is no WSL2 equivalent) --
|
|
1905
|
+
collapsing the two into one message would send an Intel Mac owner
|
|
1906
|
+
chasing a `wsl --install` that does not exist on their OS.
|
|
1907
|
+
|
|
1908
|
+
`Fail`, not `Warn`: this is the one check in the trio that means "the
|
|
1909
|
+
toolchain cannot run here at all", the same category as a missing
|
|
1910
|
+
`ninja` (`hostPrerequisites`'s own `Fail`) -- there is no artifact for
|
|
1911
|
+
`west sdk install` to fetch, and no amount of PATH or workspace fixing
|
|
1912
|
+
changes that.
|
|
1913
|
+
"""
|
|
1914
|
+
tag = f"{host_os}-{arch}"
|
|
1915
|
+
if tag in ZEPHYR_SDK_HOSTS:
|
|
1916
|
+
return Check(
|
|
1917
|
+
"zephyrSdkAvailableForHost",
|
|
1918
|
+
"pass",
|
|
1919
|
+
f"The Zephyr SDK publishes a host build for {tag}.",
|
|
1920
|
+
scope="host",
|
|
1921
|
+
)
|
|
1922
|
+
served = ", ".join(ZEPHYR_SDK_HOSTS)
|
|
1923
|
+
if tag == "windows-aarch64":
|
|
1924
|
+
detail = (
|
|
1925
|
+
f"Windows on ARM ({tag}, `windows-arm64` in Zephyr's own naming): the Zephyr "
|
|
1926
|
+
f"SDK has never published a host build for it. Served hosts are {served}. A "
|
|
1927
|
+
"native Windows build cannot be provisioned on this machine."
|
|
1928
|
+
)
|
|
1929
|
+
fix = (
|
|
1930
|
+
"Build inside WSL2 instead: install a WSL2 Linux distribution "
|
|
1931
|
+
"(`wsl --install`), then run `tan bootstrap` and `tan build` from inside it -- "
|
|
1932
|
+
"a WSL2 distro on this hardware is linux-aarch64, which the Zephyr SDK does "
|
|
1933
|
+
"publish."
|
|
1934
|
+
)
|
|
1935
|
+
elif tag == "macos-x86_64":
|
|
1936
|
+
detail = (
|
|
1937
|
+
f"Intel Mac ({tag}): the Zephyr SDK published this host through 0.17.4 and "
|
|
1938
|
+
f"dropped it in 1.0.0; the pinned SDK serves {served} only. macos-aarch64 is "
|
|
1939
|
+
"not a substitute -- Rosetta translates x86_64 for Apple silicon, not the "
|
|
1940
|
+
"reverse -- and macOS has no WSL2 equivalent to fall back to."
|
|
1941
|
+
)
|
|
1942
|
+
fix = (
|
|
1943
|
+
"Build on a Linux host: a linux-x86_64 VM or container on this Mac, or a "
|
|
1944
|
+
"remote Linux builder. Pinning an older Zephyr SDK is not an option -- the "
|
|
1945
|
+
f"pinned Zephyr requires {ZEPHYR_SDK_INSTALL_VERSION}, which is past the "
|
|
1946
|
+
"release that dropped macos-x86_64."
|
|
1947
|
+
)
|
|
1948
|
+
else:
|
|
1949
|
+
detail = f"The Zephyr SDK publishes no host build for {tag}. Served hosts are {served}."
|
|
1950
|
+
fix = f"Build on one of {served} -- natively, or in a VM/container on this machine."
|
|
1951
|
+
return Check("zephyrSdkAvailableForHost", "fail", detail, fix, scope="host")
|
|
1952
|
+
|
|
1953
|
+
|
|
1954
|
+
def _enable_long_paths_fix(key: str) -> str:
|
|
1955
|
+
"""The elevated one-liner that sets `LongPathsEnabled` -- shared by every
|
|
1956
|
+
`long_paths_check` arm that names it, so the command cannot drift between
|
|
1957
|
+
them."""
|
|
1958
|
+
return (
|
|
1959
|
+
"Enable long paths in an ELEVATED PowerShell, then reopen your shell and VS "
|
|
1960
|
+
f"Code so new processes pick it up: New-ItemProperty -Path '{key}' -Name "
|
|
1961
|
+
"LongPathsEnabled -Value 1 -PropertyType DWORD -Force"
|
|
1962
|
+
)
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
#: Fix #3 in tan-cli#306: the remedy must name this EXACT command, verbatim
|
|
1966
|
+
#: and runnable, no elevation needed (unlike `_enable_long_paths_fix`, which
|
|
1967
|
+
#: touches `HKLM`) -- the cheaper fix, and the one that unblocks the actual
|
|
1968
|
+
#: reported failure (`west update`'s own `git` calls).
|
|
1969
|
+
_GIT_LONG_PATHS_FIX = "Enable it in git: git config --global core.longpaths true"
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
def long_paths_check(registry_enabled: bool | None, git_core_longpaths: bool | None) -> Check:
|
|
1973
|
+
"""`longPaths` -- Windows only. Mirrors
|
|
1974
|
+
`tan_core::host_env::long_paths_check`.
|
|
1975
|
+
|
|
1976
|
+
Two independent axes, and conflating them into one is exactly the defect
|
|
1977
|
+
tan-cli#306 reports. `LongPathsEnabled` (the registry) governs manifested
|
|
1978
|
+
Win32 API calls (CMake, Ninja, a plain file open); it does nothing for
|
|
1979
|
+
git, which refuses any path past its own limit unless ITS OWN
|
|
1980
|
+
`core.longpaths` is set, regardless of the registry. `west update`
|
|
1981
|
+
clones/checks out every Zephyr module with `git`, so on a fresh `HOME`
|
|
1982
|
+
(no global `.gitconfig` -- a first-run customer's exact state) the
|
|
1983
|
+
registry read alone reported `pass` while `west update` died on
|
|
1984
|
+
`hal_nxp`'s `tf-psa-crypto` vendor tree with "Filename too long".
|
|
1985
|
+
|
|
1986
|
+
**`Fail`, not `Warn`, exactly when the registry reads enabled and git's
|
|
1987
|
+
does not.** That combination is not a probability the way a bare
|
|
1988
|
+
disabled registry flag is: `west update` runs `git`, `git` is the first
|
|
1989
|
+
thing in the whole toolchain to touch a long path, and its own setting
|
|
1990
|
+
says no -- the break is certain. Anything softer here would repeat the
|
|
1991
|
+
exact defect this check exists to fix.
|
|
1992
|
+
|
|
1993
|
+
**`Warn`, not `Fail` or `Pass`, when git is set but the registry is
|
|
1994
|
+
not.** Git manages long paths on its own once `core.longpaths=true` (it
|
|
1995
|
+
prefixes paths with `\\\\?\\` internally and never consults the
|
|
1996
|
+
registry), so the specific failure this check exists to catch will not
|
|
1997
|
+
reproduce -- but `LongPathsEnabled` still governs every OTHER manifested
|
|
1998
|
+
tool in the chain, so real residual risk remains.
|
|
1999
|
+
|
|
2000
|
+
**`Warn` when neither is set** -- the original, pre-#306 severity for a
|
|
2001
|
+
bare disabled registry flag: workspace-root-depth-dependent, not
|
|
2002
|
+
certain.
|
|
2003
|
+
"""
|
|
2004
|
+
key = r"HKLM\SYSTEM\CurrentControlSet\Control\FileSystem"
|
|
2005
|
+
registry_on = registry_enabled is True
|
|
2006
|
+
git_on = git_core_longpaths is True
|
|
2007
|
+
|
|
2008
|
+
if registry_enabled is True:
|
|
2009
|
+
registry_detail = f"{key}\\LongPathsEnabled = 1"
|
|
2010
|
+
elif registry_enabled is False:
|
|
2011
|
+
registry_detail = f"{key}\\LongPathsEnabled is 0 or unset"
|
|
2012
|
+
else:
|
|
2013
|
+
registry_detail = f"{key}\\LongPathsEnabled could not be read"
|
|
2014
|
+
|
|
2015
|
+
if git_core_longpaths is True:
|
|
2016
|
+
git_detail = "git core.longpaths is true"
|
|
2017
|
+
elif git_core_longpaths is False:
|
|
2018
|
+
git_detail = "git core.longpaths is unset or false"
|
|
2019
|
+
else:
|
|
2020
|
+
git_detail = "git core.longpaths could not be determined"
|
|
2021
|
+
|
|
2022
|
+
if registry_on and git_on:
|
|
2023
|
+
status = "pass"
|
|
2024
|
+
headline = "Windows long paths are enabled at both the OS level and in git."
|
|
2025
|
+
fix = None
|
|
2026
|
+
elif registry_on and not git_on:
|
|
2027
|
+
status = "fail"
|
|
2028
|
+
headline = (
|
|
2029
|
+
"Windows reports long paths enabled, but git does not honour that flag: git "
|
|
2030
|
+
"has its own core.longpaths and refuses paths past its limit without it, "
|
|
2031
|
+
"regardless of the registry. west update runs git, so bootstrap WILL fail on "
|
|
2032
|
+
"a long Zephyr module path (e.g. hal_nxp's tf-psa-crypto vendor tree) even "
|
|
2033
|
+
"though this host looks fine."
|
|
2034
|
+
)
|
|
2035
|
+
fix = _GIT_LONG_PATHS_FIX
|
|
2036
|
+
elif git_on:
|
|
2037
|
+
status = "warn"
|
|
2038
|
+
headline = (
|
|
2039
|
+
"git's own core.longpaths is set, so west update's git operations are safe. "
|
|
2040
|
+
"Windows' LongPathsEnabled is not, though, and every OTHER tool in the build "
|
|
2041
|
+
"chain (CMake, Ninja, plain Win32 file APIs) relies on it -- a sufficiently "
|
|
2042
|
+
"deep workspace can still cross MAX_PATH outside of git."
|
|
2043
|
+
)
|
|
2044
|
+
fix = _enable_long_paths_fix(key)
|
|
2045
|
+
else:
|
|
2046
|
+
status = "warn"
|
|
2047
|
+
headline = (
|
|
2048
|
+
"Neither Windows' LongPathsEnabled nor git's core.longpaths is set. A Zephyr "
|
|
2049
|
+
"build/ tree nests deep enough to cross the 260-character MAX_PATH limit, and "
|
|
2050
|
+
'it surfaces as a git "Filename too long" error during west update, or a '
|
|
2051
|
+
"CMake/compiler error about a file that exists."
|
|
2052
|
+
)
|
|
2053
|
+
fix = f"{_GIT_LONG_PATHS_FIX}\n{_enable_long_paths_fix(key)}"
|
|
2054
|
+
|
|
2055
|
+
return Check("longPaths", status, f"{headline} ({registry_detail}; {git_detail}).", fix, scope="host")
|
|
2056
|
+
|
|
2057
|
+
|
|
2058
|
+
def tan_install_check(verdict: StaleVerdict | None, version: str) -> Check:
|
|
2059
|
+
"""`tanInstall` -- is the RUNNING `tan` behind the source it was installed
|
|
2060
|
+
from? `warn` when so (never `fail`: a stale tan still works, and `exit_code_for`
|
|
2061
|
+
reserves exit 4 for real breakage); `pass` when current OR when it cannot be
|
|
2062
|
+
told (no provenance record, offline, no git) -- an unanswerable question is
|
|
2063
|
+
not a problem. The verdict logic lives in `tan.core.doctor_stale`."""
|
|
2064
|
+
return Check(
|
|
2065
|
+
"tanInstall",
|
|
2066
|
+
"pass" if verdict is None else "warn",
|
|
2067
|
+
f"tan {version}" if verdict is None else verdict.detail,
|
|
2068
|
+
None if verdict is None else verdict.fix,
|
|
2069
|
+
scope="host",
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def _tan_install_check() -> Check:
|
|
2074
|
+
"""IO half of `tan_install_check`; `TAN_DOCTOR_OFFLINE=1` skips its one
|
|
2075
|
+
short-timeout `git ls-remote`."""
|
|
2076
|
+
from tan.version import TAN_VERSION
|
|
2077
|
+
|
|
2078
|
+
verdict = running_tan_verdict(
|
|
2079
|
+
TAN_VERSION, _resolve_git_executable(), bool(os.environ.get("TAN_DOCTOR_OFFLINE"))
|
|
2080
|
+
)
|
|
2081
|
+
return tan_install_check(verdict, TAN_VERSION)
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def home_path_check(home: str | None) -> Check:
|
|
2085
|
+
"""`homePath` -- does the home directory contain a space? Mirrors
|
|
2086
|
+
`tan_core::host_env::home_path_check`.
|
|
2087
|
+
|
|
2088
|
+
`Warn`, not `Fail`: a space in `C:\\Users\\Jane Doe` is a real historical
|
|
2089
|
+
Zephyr breakage (unquoted paths through CMake/west/Kconfig), but most of
|
|
2090
|
+
the chain quotes correctly now and plenty of hosts with a space build
|
|
2091
|
+
fine -- degraded-but-usable, not a host the toolchain cannot run on at
|
|
2092
|
+
all. `Fail` here would exit 4 for every user whose Windows account name
|
|
2093
|
+
is two words.
|
|
2094
|
+
|
|
2095
|
+
All platforms, not Windows-only: a POSIX `/home/jane doe` breaks the same
|
|
2096
|
+
way -- Windows is merely where `%USERPROFILE%` is derived from a display
|
|
2097
|
+
name the user never chose.
|
|
2098
|
+
"""
|
|
2099
|
+
if home is None:
|
|
2100
|
+
return Check(
|
|
2101
|
+
"homePath",
|
|
2102
|
+
"warn",
|
|
2103
|
+
"Could not resolve the home directory (neither USERPROFILE nor HOME is set).",
|
|
2104
|
+
"Set HOME (or USERPROFILE on Windows) -- tan resolves ~/.alp for the SDK cache "
|
|
2105
|
+
"and the global default-SDK pointer from it.",
|
|
2106
|
+
scope="host",
|
|
2107
|
+
)
|
|
2108
|
+
if " " in home:
|
|
2109
|
+
return Check(
|
|
2110
|
+
"homePath",
|
|
2111
|
+
"warn",
|
|
2112
|
+
f"Home directory contains a space: {home}. Zephyr's CMake/west/Kconfig chain "
|
|
2113
|
+
"has historically broken on unquoted paths, and a workspace created under it "
|
|
2114
|
+
"inherits the space.",
|
|
2115
|
+
"Create the workspace at a space-free path (e.g. C:\\alp or /opt/alp) and run "
|
|
2116
|
+
"tan from there with --project, rather than under the home directory.",
|
|
2117
|
+
scope="host",
|
|
2118
|
+
)
|
|
2119
|
+
return Check("homePath", "pass", f"Home directory has no spaces: {home}", scope="host")
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
# ---------------------------------------------------------------------------
|
|
2123
|
+
# Build-environment preflight (tan-cli#294 finding 2, reintroducing
|
|
2124
|
+
# tan-cli#100, #98, #159): does a build even have a shot at starting?
|
|
2125
|
+
#
|
|
2126
|
+
# Folded into PLAIN `tan doctor`, mirroring
|
|
2127
|
+
# `tan_core::preflight::build_preflight_checks` -- #100's own words for the
|
|
2128
|
+
# gap this closes: "probed nothing about the build environment and printed
|
|
2129
|
+
# byte-identical output across four materially different host states."
|
|
2130
|
+
#
|
|
2131
|
+
# `westResolved` (the venv-resolved `west` binary's own presence, tan-cli#123
|
|
2132
|
+
# reintroduced) and `zephyrWorkspace`'s severity/gating are now IN scope here
|
|
2133
|
+
# too (tan-cli#290) -- see `west_resolved_check`/`zephyr_workspace_check`'s
|
|
2134
|
+
# own docstrings. `workspace`/`zephyrVersion`/`zephyrWorkspace` below are all
|
|
2135
|
+
# sourced from the SHARED `tan.core.venv.west_workspace_dir` (tan-cli#294
|
|
2136
|
+
# review) -- ALL THREE of its steps, including the `$ZEPHYR_BASE`-derived,
|
|
2137
|
+
# manifest-verified fallback. A fourth, partial copy of the same search
|
|
2138
|
+
# (this module's own retired `_resolve_west_workspace_dir`) previously
|
|
2139
|
+
# covered only the project-tree walk and the SDK-derived layout, so a host
|
|
2140
|
+
# relying SOLELY on a manually exported `$ZEPHYR_BASE` outside both a
|
|
2141
|
+
# project tree and `<sdk-parent>` reported a false `workspace` Fail -- "no
|
|
2142
|
+
# Zephyr workspace -- run `tan bootstrap`" -- that would have the customer
|
|
2143
|
+
# bootstrap a SECOND workspace. Importing the one shared resolver closed
|
|
2144
|
+
# that gap and retired the fourth copy one commit before this one; see
|
|
2145
|
+
# `tan.core.venv.west_workspace_dir`'s own docstring for why the search
|
|
2146
|
+
# lives there and not here.
|
|
2147
|
+
# ---------------------------------------------------------------------------
|
|
2148
|
+
|
|
2149
|
+
|
|
2150
|
+
def _broken_global_default() -> str | None:
|
|
2151
|
+
"""The raw `sdkPath` `~/.alp/sdk-default` names, ONLY when that pointer
|
|
2152
|
+
file exists but its target is NOT a valid alp-sdk checkout (tan-cli#344).
|
|
2153
|
+
`None` when the pointer is absent, unreadable/malformed, or DOES resolve
|
|
2154
|
+
-- every one of those is indistinguishable from "nothing configured" and
|
|
2155
|
+
stays that way; this exists to name the one case that is not.
|
|
2156
|
+
|
|
2157
|
+
Reads the exact file `sdk_cmd.resolve_sdk_tiered` already reads
|
|
2158
|
+
(`_pointer_target(_home_alp_dir() / "sdk-default")` + `_has_loader_script`)
|
|
2159
|
+
the SAME way, purely for this one extra fact -- it changes no resolution
|
|
2160
|
+
outcome (`resolve_sdk_root_ladder`/`resolve_sdk_tiered` are untouched by
|
|
2161
|
+
this function; it is called separately, only to feed `sdk_check`'s
|
|
2162
|
+
report). `resolve_sdk_tiered` itself already tracks an analogous broken
|
|
2163
|
+
POINTER for the project-pin tier (`ActiveSdk.broken_project_pin`) and
|
|
2164
|
+
surfaces it via `project_pin_issue` regardless of which lower tier
|
|
2165
|
+
answers -- this is the same idea one tier up, for the one tier that had
|
|
2166
|
+
no such memory at all: a dangling global default fell through silently,
|
|
2167
|
+
with nothing left to report it had ever existed.
|
|
2168
|
+
"""
|
|
2169
|
+
target = _pointer_target(_home_alp_dir() / "sdk-default")
|
|
2170
|
+
if target is None or _has_loader_script(Path(target)):
|
|
2171
|
+
return None
|
|
2172
|
+
return target
|
|
2173
|
+
|
|
2174
|
+
|
|
2175
|
+
def sdk_check(
|
|
2176
|
+
sdk_root: str | None,
|
|
2177
|
+
project_scope: str | None,
|
|
2178
|
+
tier: str | None = None,
|
|
2179
|
+
unselected_candidate: str | None = None,
|
|
2180
|
+
broken_global_default: str | None = None,
|
|
2181
|
+
divergent_candidate: str | None = None,
|
|
2182
|
+
dangling_flag_root: str | None = None,
|
|
2183
|
+
) -> Check:
|
|
2184
|
+
"""`sdk` -- is an alp-sdk checkout resolved at all? Mirrors
|
|
2185
|
+
`tan_core::preflight::build_preflight_checks`'s `sdk` check.
|
|
2186
|
+
|
|
2187
|
+
`project_scope` (the `--project` value, unjoined) used to name a SCOPED
|
|
2188
|
+
`tan sdk switch <path>` fix (tan-cli#101: the `.alp/sdk-path` pointer
|
|
2189
|
+
`sdk switch` writes is scoped to `--project`, so a bare `tan sdk switch
|
|
2190
|
+
<path>` from a `tan --project <p> doctor` run would have reported success
|
|
2191
|
+
while changing nothing about THIS invocation). That fix is moot now that
|
|
2192
|
+
`sdk switch` refuses outright in every build of tan on this branch
|
|
2193
|
+
(tan-cli#305, `sdk_cmd._run_not_ported`) -- recommending it, scoped or
|
|
2194
|
+
not, was the actual dead end #305 reported, since the ONLY thing left
|
|
2195
|
+
that resolves an SDK at all is `--sdk-root`, which needs no scoping. The
|
|
2196
|
+
parameter stays (worded into the fail detail below) because `--project`
|
|
2197
|
+
is still a fact worth naming, just no longer the reason for a different
|
|
2198
|
+
remedy.
|
|
2199
|
+
|
|
2200
|
+
`tier`/`unselected_candidate` (tan-cli#301) -- a reported host named THREE
|
|
2201
|
+
different roots in one report (a leftover `globalDefault`, a stale
|
|
2202
|
+
`$ZEPHYR_BASE` workspace, and the checkout the user was actually standing
|
|
2203
|
+
in, which appeared nowhere), and `tan doctor`/`tan bootstrap` disagreed
|
|
2204
|
+
about which SDK a bare invocation meant. `GlobalDefault` outranking
|
|
2205
|
+
`Discovery` is deliberate (tan-cli#263 made pins absolute on purpose) --
|
|
2206
|
+
NO behaviour change here, only visibility: `tier` is the `SdkSourceTier`
|
|
2207
|
+
wire spelling (`sdkRootFlag`/`projectPin`/`globalDefault`/`discovery`)
|
|
2208
|
+
that answered, reported alongside the root the same way `tan sdk
|
|
2209
|
+
current`'s envelope already pairs `sdkPath` with `sourceTier`.
|
|
2210
|
+
`unselected_candidate` is a DIFFERENT checkout discoverable from cwd that
|
|
2211
|
+
a higher tier outranked (`None` when the winning tier already IS
|
|
2212
|
+
discovery, or nothing else resolves there) -- named explicitly, with how
|
|
2213
|
+
to select it, so a plausible checkout sitting right there does not read
|
|
2214
|
+
as unconsidered.
|
|
2215
|
+
|
|
2216
|
+
`broken_global_default` (tan-cli#344, `_broken_global_default` above) is
|
|
2217
|
+
the raw `sdkPath` a machine-global `~/.alp/sdk-default` pointer held when
|
|
2218
|
+
that file exists but its target is no longer a valid checkout -- only
|
|
2219
|
+
meaningful in the `sdk_root is None` branch (a global default that DID
|
|
2220
|
+
resolve never reaches this function with `sdk_root is None` at all).
|
|
2221
|
+
Before this, "I have nothing configured" and "what I configured is
|
|
2222
|
+
broken and tan silently fell through past it" printed the identical
|
|
2223
|
+
sentence: `NO_SDK_NEXT_STEPS`, which tells the user to clone a checkout
|
|
2224
|
+
and pass `--sdk-root`, with no hint the thing they already configured is
|
|
2225
|
+
dangling. Falling through stays correct (unchanged here) and exit 4
|
|
2226
|
+
stays correct (unchanged here) -- only which sentence explains it
|
|
2227
|
+
changes. `bootstrap_cmd`'s own broken-pointer messages
|
|
2228
|
+
(`global_default_pointer_fix_hint`) are the shape this matches: name the
|
|
2229
|
+
pointer file directly, never `tan sdk switch`, which refuses outright in
|
|
2230
|
+
this build (tan-cli#305) -- recommending it here would be the exact
|
|
2231
|
+
dead end #305 already fixed for the project-pin case.
|
|
2232
|
+
|
|
2233
|
+
`dangling_flag_root` (tan-cli#727) is the raw `--sdk-root` text when that
|
|
2234
|
+
flag named a path carrying no `scripts/alp_project.py`. `--sdk-root` is
|
|
2235
|
+
the terminal tier, so `resolve_sdk_root_ladder` hands it back unvalidated
|
|
2236
|
+
-- correct for a caller that only REPORTS the tier, and wrong for this
|
|
2237
|
+
one, whose entire job is answering "is my setup right". `tan build` given
|
|
2238
|
+
the same path refuses (`build_cmd`, `run_cmd`, `validate_cmd`,
|
|
2239
|
+
`clean_cmd`, `flash_cmd` each guard at their own call site with
|
|
2240
|
+
`is_sdk_root`); doctor did not, so the two commands disagreed about the
|
|
2241
|
+
same flag and it was doctor saying yes. Reported as its own sentence
|
|
2242
|
+
rather than by nulling the root into the `NO_SDK_NEXT_STEPS` branch,
|
|
2243
|
+
which tells the user to clone a checkout and pass `--sdk-root` -- the
|
|
2244
|
+
thing they just did.
|
|
2245
|
+
"""
|
|
2246
|
+
if dangling_flag_root is not None:
|
|
2247
|
+
return Check(
|
|
2248
|
+
"sdk",
|
|
2249
|
+
"fail",
|
|
2250
|
+
rejected_sdk_root_message(
|
|
2251
|
+
dangling_flag_root,
|
|
2252
|
+
"`tan build` refuses this same path, so nothing this report "
|
|
2253
|
+
"says about the SDK describes a checkout that is there.",
|
|
2254
|
+
),
|
|
2255
|
+
"Point --sdk-root at a directory holding the marker above. "
|
|
2256
|
+
"`tan bootstrap` MOVES the checkout into the workspace and "
|
|
2257
|
+
"rewrites the default (tan-cli#185), so a --sdk-root copied from "
|
|
2258
|
+
"before a bootstrap names the path it emptied.",
|
|
2259
|
+
scope="project",
|
|
2260
|
+
)
|
|
2261
|
+
if sdk_root is not None:
|
|
2262
|
+
detail = f"alp-sdk at {sdk_root}"
|
|
2263
|
+
if tier is not None:
|
|
2264
|
+
detail += f" ({tier}"
|
|
2265
|
+
if unselected_candidate is not None:
|
|
2266
|
+
detail += (
|
|
2267
|
+
f"; a checkout at {unselected_candidate} was not selected -- "
|
|
2268
|
+
f"pass --sdk-root {unselected_candidate} to use it"
|
|
2269
|
+
)
|
|
2270
|
+
detail += ")"
|
|
2271
|
+
if divergent_candidate is not None:
|
|
2272
|
+
# BOTH roots re-rendered POSIX here, unlike the `pass` branch's
|
|
2273
|
+
# `alp-sdk at {sdk_root}` above, which keeps this host's native
|
|
2274
|
+
# separators and is left byte-identical. The reason is the CODE:
|
|
2275
|
+
# this branch's detail becomes an `issues[].message` under
|
|
2276
|
+
# `sdk.discovery-divergent`, which four other commands also emit
|
|
2277
|
+
# -- and all four build it from `_abs_posix`. A consumer
|
|
2278
|
+
# correlating two envelopes would otherwise match a code across a
|
|
2279
|
+
# message spelled `C:/...` on one side and `C:\...` on the other.
|
|
2280
|
+
# The envelope is the contract; normalise the filesystem side to
|
|
2281
|
+
# meet it, never the other way round (caught by
|
|
2282
|
+
# windows-latest on tan-cli#428, green everywhere else).
|
|
2283
|
+
posix_root = f"alp-sdk at {_abs_posix(sdk_root)}"
|
|
2284
|
+
if tier is not None:
|
|
2285
|
+
posix_root += f" ({tier})"
|
|
2286
|
+
return Check(
|
|
2287
|
+
"sdk",
|
|
2288
|
+
"warn",
|
|
2289
|
+
f"{posix_root}; `tan init`, `tan generate` and "
|
|
2290
|
+
f"`tan examples` resolve a DIFFERENT checkout from this "
|
|
2291
|
+
f"directory ({_abs_posix(divergent_candidate)}) and report "
|
|
2292
|
+
f'the same sourceTier "discovery", so generated files and '
|
|
2293
|
+
f"the build plan can come from different SDK versions.",
|
|
2294
|
+
f"--sdk-root <path> to pin the one you mean for a single run, "
|
|
2295
|
+
f"or `tan init --sdk-root <path>` to write it into "
|
|
2296
|
+
f".alp/sdk-path, which outranks both discovery tiers.",
|
|
2297
|
+
code=SDK_DISCOVERY_DIVERGENT,
|
|
2298
|
+
scope="project",
|
|
2299
|
+
)
|
|
2300
|
+
return Check("sdk", "pass", detail, scope="project")
|
|
2301
|
+
scope_note = f" for --project {project_scope}" if project_scope is not None else ""
|
|
2302
|
+
if broken_global_default is not None:
|
|
2303
|
+
home = _home_alp_dir()
|
|
2304
|
+
pointer = str(home / "sdk-default")
|
|
2305
|
+
return Check(
|
|
2306
|
+
"sdk",
|
|
2307
|
+
"fail",
|
|
2308
|
+
f"no SDK selected{scope_note} -- the machine-global default "
|
|
2309
|
+
f'({pointer}) names "{broken_global_default}", which is not a '
|
|
2310
|
+
f"valid alp-sdk checkout, so tan fell through past it and found "
|
|
2311
|
+
f"nothing else either.",
|
|
2312
|
+
f"{global_default_pointer_fix_hint(pointer, str(registry_path(home)))}, or "
|
|
2313
|
+
f"pass --sdk-root <path> directly.",
|
|
2314
|
+
scope="project",
|
|
2315
|
+
)
|
|
2316
|
+
return Check(
|
|
2317
|
+
"sdk",
|
|
2318
|
+
"fail",
|
|
2319
|
+
f"no SDK selected{scope_note} -- {NO_SDK_NEXT_STEPS}",
|
|
2320
|
+
"--sdk-root <path>",
|
|
2321
|
+
scope="project",
|
|
2322
|
+
)
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def board_yaml_preflight_check(present: bool, project_selected: bool) -> Check:
|
|
2326
|
+
"""`boardYaml` -- mirrors `build_preflight_checks`'s check of the same
|
|
2327
|
+
name, PLUS the project-selection awareness the Rust oracle's debug
|
|
2328
|
+
report has and this port's copy used to lack (tan-cli#294 review,
|
|
2329
|
+
reintroducing #100(b)): `tan bootstrap` prints `tan doctor` as the very
|
|
2330
|
+
next command, run from the SDK checkout root it just set up -- which has
|
|
2331
|
+
no `board.yaml` and needs none. Failing there made the first command a
|
|
2332
|
+
new customer types report `1 failed` and exit 4 for a non-problem.
|
|
2333
|
+
|
|
2334
|
+
`project_selected` is True only when `--project` or `--board-yaml` was
|
|
2335
|
+
actually given (mirrors `crates/tan-cli/src/commands/doctor.rs::
|
|
2336
|
+
project_selected` -- with neither flag the resolved path is a guess at
|
|
2337
|
+
the cwd, not a request) and is only read when `present` is False.
|
|
2338
|
+
|
|
2339
|
+
NOT a duplicate of a debug-report `boardYaml` check (this port has not
|
|
2340
|
+
built the debug half -- see the module docstring), so this is the only
|
|
2341
|
+
`boardYaml` check in this file and it is never dropped.
|
|
2342
|
+
"""
|
|
2343
|
+
if present:
|
|
2344
|
+
return Check("boardYaml", "pass", "board.yaml found", scope="project")
|
|
2345
|
+
if project_selected:
|
|
2346
|
+
return Check(
|
|
2347
|
+
"boardYaml",
|
|
2348
|
+
"fail",
|
|
2349
|
+
"board.yaml not found -- run `tan init` or pass `--board-yaml <path>`",
|
|
2350
|
+
"tan init",
|
|
2351
|
+
scope="project",
|
|
2352
|
+
)
|
|
2353
|
+
return Check(
|
|
2354
|
+
"boardYaml",
|
|
2355
|
+
"warn",
|
|
2356
|
+
"no project selected -- no board.yaml found",
|
|
2357
|
+
"Select a project with `--project <dir>` (or `--board-yaml <path>`) to check one.",
|
|
2358
|
+
scope="project",
|
|
2359
|
+
)
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def libraries_check(report: LibraryReport | None) -> Check | None:
|
|
2363
|
+
"""`libraries` -- the ADR-0018 curated-library verdict for the selected
|
|
2364
|
+
project: tier, licence, and whether each selection can actually be wired
|
|
2365
|
+
on this target.
|
|
2366
|
+
|
|
2367
|
+
Ported from alp-sdk's `scripts/alp_cli/doctor.py::_check_libraries`
|
|
2368
|
+
(ADR-0020 end-state B -- a user-facing check belongs on the user command
|
|
2369
|
+
surface, which is `tan`; alp-sdk keeps the library LAYER, not a second CLI
|
|
2370
|
+
reporting on it). `tan.core.doctor_libraries` owns the resolution, the
|
|
2371
|
+
outcome vocabulary and the defect the port fixed; this is the mapping.
|
|
2372
|
+
|
|
2373
|
+
`None` -- no row at all -- for no project in scope and for one selecting
|
|
2374
|
+
no libraries; a permanent "0 selected" line would sit in front of every
|
|
2375
|
+
customer not using the feature, and `venv_provenance_check` is precedent
|
|
2376
|
+
for declining to speak over speaking emptily.
|
|
2377
|
+
|
|
2378
|
+
Never `fail`: `tan build` refuses an unwireable selection outright, so the
|
|
2379
|
+
hard gate exists already and is the emit, and a `fail` here would also
|
|
2380
|
+
cost exit 4 -- making "you picked a library this board cannot run" read
|
|
2381
|
+
identically to "you have no toolchain". alp-sdk's original call, kept.
|
|
2382
|
+
`scope="project"` because the subject is that board.yaml against that
|
|
2383
|
+
checkout, which answers identically on every host (`doctor_scope`)."""
|
|
2384
|
+
if report is None:
|
|
2385
|
+
return None
|
|
2386
|
+
joined = ", ".join(report.labels)
|
|
2387
|
+
count = len(report.names)
|
|
2388
|
+
if report.outcome == "ok":
|
|
2389
|
+
return Check(
|
|
2390
|
+
"libraries", "pass", f"{count} selected, all compatible: {joined}", scope="project"
|
|
2391
|
+
)
|
|
2392
|
+
if report.outcome == "unreadable":
|
|
2393
|
+
return Check("libraries", "warn", report.detail, scope="project")
|
|
2394
|
+
if report.outcome == "unverifiable":
|
|
2395
|
+
return Check(
|
|
2396
|
+
"libraries",
|
|
2397
|
+
"warn",
|
|
2398
|
+
f"{count} selected, none verified ({joined}) -- {report.detail}",
|
|
2399
|
+
# `NO_SDK_NEXT_STEPS`, never a hand-written `tan sdk switch` --
|
|
2400
|
+
# that verb is refused (test_sdk_onboarding_dead_end.py).
|
|
2401
|
+
f"{NO_SDK_NEXT_STEPS}, then re-run `tan doctor`.",
|
|
2402
|
+
scope="project",
|
|
2403
|
+
)
|
|
2404
|
+
return Check(
|
|
2405
|
+
"libraries",
|
|
2406
|
+
"warn",
|
|
2407
|
+
f"{count} selected: {joined} -- INCOMPATIBLE: {report.detail}",
|
|
2408
|
+
"Fix or remove the offending `libraries:` entry in board.yaml; `tan build` "
|
|
2409
|
+
"rejects it as a hard error.",
|
|
2410
|
+
scope="project",
|
|
2411
|
+
)
|
|
2412
|
+
|
|
2413
|
+
|
|
2414
|
+
def workspace_patches_check(result: PatchCheck, workspace_dir: str) -> Check:
|
|
2415
|
+
"""`workspacePatches` (tan-cli#1376) -- is alp-sdk's `zephyr/patches.yml`
|
|
2416
|
+
applied in the resolved workspace? A tree without them still BUILDS; the
|
|
2417
|
+
gap shows up on the device (`alp_camera_open` -> `ALP_ERR_NOSUPPORT` for a
|
|
2418
|
+
missing Alif clock `set_rate`). `unknown` (never a failure) when the SDK has
|
|
2419
|
+
no verifier or the check could not run, so an offline doctor stays green."""
|
|
2420
|
+
if result.state == WORKSPACE_PATCHES_APPLIED:
|
|
2421
|
+
return Check(
|
|
2422
|
+
"workspacePatches", "pass",
|
|
2423
|
+
f"zephyr/patches.yml {result.note} in {workspace_dir}", scope="project",
|
|
2424
|
+
)
|
|
2425
|
+
if result.state == WORKSPACE_PATCHES_MISSING:
|
|
2426
|
+
return Check(
|
|
2427
|
+
"workspacePatches",
|
|
2428
|
+
"warn",
|
|
2429
|
+
f"alp-sdk's zephyr/patches.yml is not applied in {workspace_dir}: "
|
|
2430
|
+
f"{describe_unapplied(result.patches, result.modules)}. The build still succeeds, "
|
|
2431
|
+
"but features that need them fail at runtime (for example `alp_camera_open` "
|
|
2432
|
+
f"returns ALP_ERR_NOSUPPORT). Fix: {patch_fix_text(result.modules, workspace_dir)}.",
|
|
2433
|
+
"tan bootstrap",
|
|
2434
|
+
scope="project",
|
|
2435
|
+
)
|
|
2436
|
+
return Check(
|
|
2437
|
+
"workspacePatches", "unknown", f"patches not checked: {result.note}.", scope="project"
|
|
2438
|
+
)
|
|
2439
|
+
|
|
2440
|
+
|
|
2441
|
+
def zephyr_base_check(env_value: str | None, workspace_dir: str) -> Check | None:
|
|
2442
|
+
"""`zephyrBase` (tan-cli#1376) -- only when `$ZEPHYR_BASE` names a tree other
|
|
2443
|
+
than the resolved workspace's zephyr, which tan ignores. A `pass` carrying
|
|
2444
|
+
the explanation: it is information, not a problem with the host."""
|
|
2445
|
+
note = zephyr_base_note(env_value, str(Path(workspace_dir) / "zephyr"))
|
|
2446
|
+
if note is None:
|
|
2447
|
+
return None
|
|
2448
|
+
return Check("zephyrBase", "pass", note, scope="project")
|
|
2449
|
+
|
|
2450
|
+
|
|
2451
|
+
def workspace_preflight_check(
|
|
2452
|
+
workspace_dir: str | None,
|
|
2453
|
+
*,
|
|
2454
|
+
start: str | None = None,
|
|
2455
|
+
sdk_root: str | None = None,
|
|
2456
|
+
zephyr_base: str | None = None,
|
|
2457
|
+
) -> Check:
|
|
2458
|
+
"""`workspace` -- is a Zephyr WORKSPACE (a directory holding `.west/`)
|
|
2459
|
+
resolved at all? Mirrors `build_preflight_checks`'s check of the same
|
|
2460
|
+
name. Distinct from `hostPrerequisites`/`west` above, which only confirm
|
|
2461
|
+
the TOOLS needed to build are on PATH -- neither confirms a Zephyr tree
|
|
2462
|
+
exists to build against.
|
|
2463
|
+
|
|
2464
|
+
tan-cli#1432: with `start` and `sdk_root` known, an unresolved workspace
|
|
2465
|
+
is judged by what `tan build` will then do (see `tan.core.
|
|
2466
|
+
west_workspace_refusal.unresolved_workspace_verdict`), not reported as a
|
|
2467
|
+
bare "no Zephyr workspace" when west's own walk still finds one.
|
|
2468
|
+
"""
|
|
2469
|
+
if workspace_dir is not None:
|
|
2470
|
+
return Check("workspace", "pass", f"Zephyr workspace at {workspace_dir}", scope="project")
|
|
2471
|
+
status, detail = "fail", (
|
|
2472
|
+
"no Zephyr workspace -- run `tan bootstrap` (reuses a compatible Zephyr, else "
|
|
2473
|
+
"bootstraps one)"
|
|
2474
|
+
)
|
|
2475
|
+
if start is not None and sdk_root is not None:
|
|
2476
|
+
status, detail = unresolved_workspace_verdict(Path(start), zephyr_base, Path(sdk_root))
|
|
2477
|
+
return Check("workspace", status, detail, "tan bootstrap", scope="project")
|
|
2478
|
+
|
|
2479
|
+
|
|
2480
|
+
def zephyr_version_preflight_check(
|
|
2481
|
+
workspace_version: str | None, sdk_pin: str | None
|
|
2482
|
+
) -> Check | None:
|
|
2483
|
+
"""`zephyrVersion` -- does a REUSED workspace's Zephyr match the active
|
|
2484
|
+
SDK's `west.yml` pin? Mirrors `build_preflight_checks`'s check
|
|
2485
|
+
(tan-cli#98/#159): compared at full `MAJOR.MINOR.PATCH`, because a
|
|
2486
|
+
truncated `MAJOR.MINOR` comparison let a patch-level pin bump
|
|
2487
|
+
(`v4.4.0` -> `v4.4.1`) read as a match -- the drifted-checkout shape of
|
|
2488
|
+
the alp-sdk#855 incident.
|
|
2489
|
+
|
|
2490
|
+
`None` (no check emitted) when either side is unknown, matching Rust's
|
|
2491
|
+
own skip: don't nag when this cannot actually be verified.
|
|
2492
|
+
|
|
2493
|
+
**`Fail`, not `Warn`** (#159): a reused workspace on the wrong Zephyr
|
|
2494
|
+
does not "maybe" break the build -- it compiles against a different
|
|
2495
|
+
Zephyr than the plan was emitted for, and a Warn here is indistinguishable
|
|
2496
|
+
from a check that can never fail.
|
|
2497
|
+
"""
|
|
2498
|
+
if workspace_version is None or sdk_pin is None:
|
|
2499
|
+
return None
|
|
2500
|
+
if workspace_version == sdk_pin:
|
|
2501
|
+
return Check(
|
|
2502
|
+
"zephyrVersion", "pass", f"Zephyr v{workspace_version} matches the SDK pin",
|
|
2503
|
+
scope="project",
|
|
2504
|
+
)
|
|
2505
|
+
return Check(
|
|
2506
|
+
"zephyrVersion",
|
|
2507
|
+
"fail",
|
|
2508
|
+
f"reused Zephyr v{workspace_version} != SDK pin v{sdk_pin} -- run `tan bootstrap` "
|
|
2509
|
+
"to refresh the workspace",
|
|
2510
|
+
"tan bootstrap",
|
|
2511
|
+
scope="project",
|
|
2512
|
+
)
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
# ---------------------------------------------------------------------------
|
|
2516
|
+
# Venv provenance (tan-cli#292 consequences 1 and 3).
|
|
2517
|
+
# ---------------------------------------------------------------------------
|
|
2518
|
+
|
|
2519
|
+
|
|
2520
|
+
def venv_provenance_check(record: WorkspaceSdkRecord | None, sdk_root: str | None) -> Check | None:
|
|
2521
|
+
"""`venvProvenance` -- does the RESOLVED workspace venv's tan-written
|
|
2522
|
+
record (`<topdir>/.west/tan-workspace-sdk`, tan-cli#292) name the SAME SDK
|
|
2523
|
+
this report resolved against? Catches two of #292's three consequences:
|
|
2524
|
+
`tan sdk switch` leaving the venv behind (consequence 3 -- the record
|
|
2525
|
+
still names the SDK that last populated it), and a neighbouring project's
|
|
2526
|
+
venv winning `find_workspace_venv`'s upward walk when that venv is ITSELF
|
|
2527
|
+
tan-bootstrapped, just for a different SDK (consequence 1 -- its own
|
|
2528
|
+
record then names a project this report was never asked about). Both
|
|
2529
|
+
otherwise surface only later, as a Zephyr build failing on a
|
|
2530
|
+
wrong-version package that names the SYMPTOM, not the cause.
|
|
2531
|
+
|
|
2532
|
+
**A WARNING, not a re-resolution (tan-cli#292 rc3 scope).** The record is
|
|
2533
|
+
not yet the resolver's primary source -- `tan build` still uses whatever
|
|
2534
|
+
`find_workspace_venv`'s search resolved; this only tells the customer
|
|
2535
|
+
that venv's packages may not match BEFORE a build fails on it. Consequence
|
|
2536
|
+
1's upward-walk case is caught only when the neighbouring venv carries its
|
|
2537
|
+
OWN record; one populated by a bare `west update` with no tan involvement
|
|
2538
|
+
anywhere still resolves silently -- the same gap `west_workspace_dir`'s
|
|
2539
|
+
`$ZEPHYR_BASE` manifest guard cannot close for an unrelated tree with no
|
|
2540
|
+
alp-sdk manifest to check against either. The full record-primary
|
|
2541
|
+
resolver the issue also proposes is out of scope for this fix; see the
|
|
2542
|
+
issue for the follow-up.
|
|
2543
|
+
|
|
2544
|
+
`None` (no check emitted, matching `zephyr_version_preflight_check`'s own
|
|
2545
|
+
skip) when there is nothing to compare: no venv resolved, it carries no
|
|
2546
|
+
record at all -- a workspace bootstrapped by alp-sdk's own `bootstrap.sh`
|
|
2547
|
+
writes none (`crates/tan-cli/src/venv.rs:25-27`), and neither does a tan
|
|
2548
|
+
predating tan-cli#292 -- or no `sdk_root` resolved to compare against.
|
|
2549
|
+
"""
|
|
2550
|
+
if record is None or sdk_root is None:
|
|
2551
|
+
return None
|
|
2552
|
+
if os.path.normcase(_abs_posix(record.sdk_path)) == os.path.normcase(_abs_posix(sdk_root)):
|
|
2553
|
+
return Check(
|
|
2554
|
+
"venvProvenance", "pass", f"workspace venv populated for the active SDK ({record.sdk_path})",
|
|
2555
|
+
scope="project",
|
|
2556
|
+
)
|
|
2557
|
+
return Check(
|
|
2558
|
+
"venvProvenance",
|
|
2559
|
+
"warn",
|
|
2560
|
+
f"workspace venv was populated for a different SDK ({record.sdk_path}) than the "
|
|
2561
|
+
f"one currently selected ({sdk_root}) -- Zephyr packages installed into it may not "
|
|
2562
|
+
"match; run `tan bootstrap` to resync the venv",
|
|
2563
|
+
"tan bootstrap",
|
|
2564
|
+
scope="project",
|
|
2565
|
+
)
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
# ---------------------------------------------------------------------------
|
|
2569
|
+
# SDK provenance (tan-cli#294 finding 5; no numbered GH issue -- the Rust
|
|
2570
|
+
# doc comment cites "conformance Issue 4 + 6").
|
|
2571
|
+
# ---------------------------------------------------------------------------
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
def sdk_provenance_check(sdk_root: str) -> Check:
|
|
2575
|
+
"""`sdkProvenance` -- records the SDK checkout's git short-commit and
|
|
2576
|
+
`metadata/sdk_version.yaml` version, so a build plan can be traced back
|
|
2577
|
+
to the planner that produced it, and warns when the checkout is behind
|
|
2578
|
+
its upstream tracking ref. Mirrors
|
|
2579
|
+
`crates/tan-cli/src/commands/doctor.rs`'s `append_sdk_provenance`.
|
|
2580
|
+
|
|
2581
|
+
Advisory only: `git_behind_upstream` reads the local remote-tracking ref
|
|
2582
|
+
and performs no network fetch, so it only reflects the checkout's state
|
|
2583
|
+
as of the last `git fetch` -- never blocks a build over it.
|
|
2584
|
+
"""
|
|
2585
|
+
# tan-cli#797: resolved ONCE and threaded through both git calls below --
|
|
2586
|
+
# each spawns `git` itself, and re-resolving per call would not be wrong,
|
|
2587
|
+
# only wasteful; the point that matters is that neither one hands
|
|
2588
|
+
# `subprocess` the bare `"git"` (see `_resolve_git_executable`).
|
|
2589
|
+
git_exe = _resolve_git_executable()
|
|
2590
|
+
commit = _git_short_commit(sdk_root, git_exe)
|
|
2591
|
+
version = _read_sdk_version(sdk_root)
|
|
2592
|
+
if version and commit:
|
|
2593
|
+
detail = f"alp-sdk {version} @ {commit}"
|
|
2594
|
+
elif commit:
|
|
2595
|
+
detail = f"alp-sdk @ {commit}"
|
|
2596
|
+
elif version:
|
|
2597
|
+
detail = f"alp-sdk {version}"
|
|
2598
|
+
else:
|
|
2599
|
+
detail = f"alp-sdk at {sdk_root} (no git checkout / metadata/sdk_version.yaml)"
|
|
2600
|
+
|
|
2601
|
+
behind = _git_behind_upstream(sdk_root, git_exe)
|
|
2602
|
+
if behind is not None and behind > 0:
|
|
2603
|
+
return Check(
|
|
2604
|
+
"sdkProvenance",
|
|
2605
|
+
"warn",
|
|
2606
|
+
f"{detail} -- {behind} commit(s) behind upstream",
|
|
2607
|
+
f"Update the SDK checkout: git -C {sdk_root} pull",
|
|
2608
|
+
scope="project",
|
|
2609
|
+
)
|
|
2610
|
+
return Check("sdkProvenance", "pass", detail, scope="project")
|
|
2611
|
+
|
|
2612
|
+
|
|
2613
|
+
# _resolve_git_executable / _is_own_git_checkout / _git_short_commit /
|
|
2614
|
+
# _git_behind_upstream moved to tan.core.doctor_git (tan-cli#797 module-size
|
|
2615
|
+
# budget); imported above.
|
|
2616
|
+
|
|
2617
|
+
def _read_sdk_version(root: str) -> str | None:
|
|
2618
|
+
"""Read a version from `<root>/metadata/sdk_version.yaml`. Shares
|
|
2619
|
+
`sdk_cmd.parse_sdk_version_yaml` with `check_sdk_readiness`
|
|
2620
|
+
(tan-cli#162), so `tan sdk install`/`current`/`switch` and this check
|
|
2621
|
+
read the SAME version out of the SAME file rather than two copies of the
|
|
2622
|
+
scan able to disagree."""
|
|
2623
|
+
text = _read_text(Path(root) / "metadata" / "sdk_version.yaml")
|
|
2624
|
+
if text is None:
|
|
2625
|
+
return None
|
|
2626
|
+
return parse_sdk_version_yaml(text)
|
|
2627
|
+
|
|
2628
|
+
|
|
2629
|
+
# ---------------------------------------------------------------------------
|
|
2630
|
+
# Aggregation
|
|
2631
|
+
# ---------------------------------------------------------------------------
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
def summarise(checks: list[Check]) -> dict[str, int]:
|
|
2635
|
+
"""`pass`/`warn`/`fail` counts. `unknown` lands in NONE of them, so
|
|
2636
|
+
`sum(summary.values())` can be smaller than `len(checks)` -- deliberate, and
|
|
2637
|
+
the same shape the Rust `DoctorSummary` has."""
|
|
2638
|
+
return {
|
|
2639
|
+
"pass": sum(1 for c in checks if c.status == "pass"),
|
|
2640
|
+
"warn": sum(1 for c in checks if c.status == "warn"),
|
|
2641
|
+
"fail": sum(1 for c in checks if c.status == "fail"),
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
def next_steps(checks: list[Check]) -> list[str]:
|
|
2646
|
+
"""Deduplicated fixes for non-passing checks. `unknown` contributes none:
|
|
2647
|
+
a check nobody could run has nothing to remediate."""
|
|
2648
|
+
steps: list[str] = []
|
|
2649
|
+
for check in checks:
|
|
2650
|
+
if check.status in ("pass", "unknown") or check.fix is None:
|
|
2651
|
+
continue
|
|
2652
|
+
if check.fix not in steps:
|
|
2653
|
+
steps.append(check.fix)
|
|
2654
|
+
return steps
|
|
2655
|
+
|
|
2656
|
+
|
|
2657
|
+
_CAMEL_BOUNDARY = re.compile(r"(?<!^)(?=[A-Z])")
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
def kebab_check_name(name: str) -> str:
|
|
2661
|
+
"""`Check.name` (camelCase, also a JSON data key/display string -- left
|
|
2662
|
+
alone) -> the kebab-case slug an issue *code* uses, e.g. `'sevenZip'` ->
|
|
2663
|
+
`'seven-zip'`. `contract/issue-codes.json`'s registry gate
|
|
2664
|
+
(`^[a-z][a-z0-9-]*\\.[a-z0-9-]+$`) rejects camelCase; 27 issue codes
|
|
2665
|
+
derived straight from `check.name` shipped camelCase in v0.5.0 before this
|
|
2666
|
+
existed."""
|
|
2667
|
+
return _CAMEL_BOUNDARY.sub("-", name).lower()
|
|
2668
|
+
|
|
2669
|
+
|
|
2670
|
+
def checks_to_issues(checks: list[Check]) -> list[Issue]:
|
|
2671
|
+
"""Warn/fail checks become issues; `unknown` raises none (it is not a
|
|
2672
|
+
problem, the question was simply not askable). The code is the check's own
|
|
2673
|
+
when it has one -- the frozen `bootstrap.*` spellings -- else Rust's
|
|
2674
|
+
`doctor.<checkName>` convention, kebab-cased (`kebab_check_name`)."""
|
|
2675
|
+
return [
|
|
2676
|
+
Issue(
|
|
2677
|
+
check.code or f"doctor.{kebab_check_name(check.name)}",
|
|
2678
|
+
"error" if check.status == "fail" else "warning",
|
|
2679
|
+
check.detail,
|
|
2680
|
+
)
|
|
2681
|
+
for check in checks
|
|
2682
|
+
if check.status in ("warn", "fail")
|
|
2683
|
+
]
|
|
2684
|
+
|
|
2685
|
+
|
|
2686
|
+
def exit_code_for(checks: list[Check]) -> ExitCode:
|
|
2687
|
+
"""Exit 4 on any failure. Never 0 on an unhealthy host: a green doctor over a
|
|
2688
|
+
broken environment converts a fixable setup problem into a mystery inside
|
|
2689
|
+
somebody else's build system."""
|
|
2690
|
+
return (
|
|
2691
|
+
ExitCode.DOCTOR_FAILURE
|
|
2692
|
+
if any(c.status == "fail" for c in checks)
|
|
2693
|
+
else ExitCode.SUCCESS
|
|
2694
|
+
)
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
# ---------------------------------------------------------------------------
|
|
2698
|
+
# The IO layer: probe the host, then hand facts to the pure checks above
|
|
2699
|
+
# ---------------------------------------------------------------------------
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
#: `platform.machine()` -> the Zephyr-SDK-release arch token
|
|
2703
|
+
#: (`tan_core::host_env::ZEPHYR_SDK_HOSTS`'s spelling). Values seen in
|
|
2704
|
+
#: practice: Windows `AMD64`/`ARM64`, macOS `x86_64`/`arm64`, Linux
|
|
2705
|
+
#: `x86_64`/`aarch64`. An unrecognised value is passed through unchanged, so
|
|
2706
|
+
#: `zephyr_sdk_host_check` reports it as a real, unserved tag rather than
|
|
2707
|
+
#: silently mapping it onto a served one.
|
|
2708
|
+
_ARCH_TAGS = {
|
|
2709
|
+
"amd64": "x86_64",
|
|
2710
|
+
"x86_64": "x86_64",
|
|
2711
|
+
"arm64": "aarch64",
|
|
2712
|
+
"aarch64": "aarch64",
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
|
|
2716
|
+
def _macos_rosetta_translated() -> bool:
|
|
2717
|
+
"""`True` when THIS process's Python interpreter is an x86_64 binary
|
|
2718
|
+
running under Rosetta on Apple silicon -- `sysctl -n
|
|
2719
|
+
sysctl.proc_translated` == 1. Mirrors
|
|
2720
|
+
`tan_core::host_env::arch_for_proc_translated`'s macOS probe
|
|
2721
|
+
(`crates/tan-cli/src/commands/doctor.rs:601-611`) via the `sysctl` CLI
|
|
2722
|
+
rather than a `ctypes` binding to the same `sysctlbyname` FFI -- this
|
|
2723
|
+
module's probes are all subprocess-based, and the sysctl is a stable
|
|
2724
|
+
macOS command-line surface. `probe()` (and so this) returns `False` on a
|
|
2725
|
+
pre-Big-Sur host where the sysctl does not exist -- the compiled arch is
|
|
2726
|
+
already correct there, matching Rust's `rc == 0 && translated == 1`.
|
|
2727
|
+
|
|
2728
|
+
`sysctl` itself is resolved through `tool_lookup.resolve_tool` before the
|
|
2729
|
+
spawn (tan-cli#797), the same treatment as this module's other probes --
|
|
2730
|
+
POSIX `execvp` never searches the current directory the way Windows'
|
|
2731
|
+
`CreateProcess` does, so a bare `argv[0]` here was never the strong form
|
|
2732
|
+
of the hazard, but leaving one spawn unresolved while the rest of the
|
|
2733
|
+
module resolves would be a needless inconsistency for the next reader to
|
|
2734
|
+
puzzle over. `False` when `sysctl` is not on PATH at all -- the same
|
|
2735
|
+
"nothing to report" verdict as every other way this probe can fail.
|
|
2736
|
+
"""
|
|
2737
|
+
resolved = resolve_tool("sysctl", os.environ).resolved
|
|
2738
|
+
if resolved is None:
|
|
2739
|
+
return False
|
|
2740
|
+
return (
|
|
2741
|
+
probe(["sysctl", "-n", "sysctl.proc_translated"], executable=resolved) or ""
|
|
2742
|
+
).strip() == "1"
|
|
2743
|
+
|
|
2744
|
+
|
|
2745
|
+
def _host_os_arch_tags() -> tuple[str, str]:
|
|
2746
|
+
"""`(os, arch)` in `tan_core::host_env::ZEPHYR_SDK_HOSTS`'s tokens, read
|
|
2747
|
+
from `platform.system()`/`platform.machine()`, corrected for Rosetta.
|
|
2748
|
+
|
|
2749
|
+
Unlike the Rust oracle, this does NOT detect Windows-on-ARM x64 emulation
|
|
2750
|
+
(`IsWow64Process2`): tan's Python port runs under whatever interpreter is
|
|
2751
|
+
already installed rather than a separately-compiled per-arch binary, so
|
|
2752
|
+
`platform.machine()` reflects the INTERPRETER's real architecture in the
|
|
2753
|
+
overwhelming majority of cases (a user who installed an x86_64 Python on
|
|
2754
|
+
Windows-on-ARM, where Python.org has shipped a native ARM64 installer for
|
|
2755
|
+
some time, is the one host this can under-report -- tracked, not silently
|
|
2756
|
+
claimed complete).
|
|
2757
|
+
|
|
2758
|
+
macOS IS corrected (tan-cli#294 review): the opposite direction is common
|
|
2759
|
+
there and worse. Rosetta silently runs the far more widely distributed
|
|
2760
|
+
x86_64 Python build on Apple silicon, so `platform.machine()` alone
|
|
2761
|
+
reported `macos-x86_64` -- a FALSE HARD REFUSAL
|
|
2762
|
+
(`zephyr_sdk_host_check`'s `Fail`, exit 4, "build on a Linux host") on
|
|
2763
|
+
hardware the pinned SDK serves natively as `macos-aarch64`.
|
|
2764
|
+
"""
|
|
2765
|
+
system = platform.system().lower()
|
|
2766
|
+
host_os = {"windows": "windows", "darwin": "macos", "linux": "linux"}.get(system, system)
|
|
2767
|
+
machine = platform.machine().lower()
|
|
2768
|
+
arch = _ARCH_TAGS.get(machine, machine)
|
|
2769
|
+
if host_os == "macos" and arch == "x86_64" and _macos_rosetta_translated():
|
|
2770
|
+
arch = "aarch64"
|
|
2771
|
+
return host_os, arch
|
|
2772
|
+
|
|
2773
|
+
|
|
2774
|
+
# classify_git_core_longpaths / _git_core_longpaths moved to
|
|
2775
|
+
# tan.core.doctor_git (tan-cli#797 module-size budget); imported above.
|
|
2776
|
+
|
|
2777
|
+
|
|
2778
|
+
def _long_paths_enabled() -> bool | None:
|
|
2779
|
+
"""Windows `HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\
|
|
2780
|
+
LongPathsEnabled`, via the stdlib `winreg` module (Windows-only).
|
|
2781
|
+
|
|
2782
|
+
`None` off Windows (`long_paths_check` is never reached there --
|
|
2783
|
+
`_collect` gates the append on `os.name == "nt"`) and on any registry
|
|
2784
|
+
read failure OTHER than the value/subkey being absent -- an access
|
|
2785
|
+
denial, a value of the wrong type -- so the check can say "unknown"
|
|
2786
|
+
rather than guess. An absent value/subkey (`FileNotFoundError`) IS
|
|
2787
|
+
"disabled": that is the Windows default-off state and by far the most
|
|
2788
|
+
common one, matching `tan_core::host_env::classify_long_paths`.
|
|
2789
|
+
"""
|
|
2790
|
+
if os.name != "nt":
|
|
2791
|
+
return None
|
|
2792
|
+
try:
|
|
2793
|
+
import winreg
|
|
2794
|
+
except ImportError: # pragma: no cover -- always present on Windows CPython
|
|
2795
|
+
return None
|
|
2796
|
+
try:
|
|
2797
|
+
with winreg.OpenKey(
|
|
2798
|
+
winreg.HKEY_LOCAL_MACHINE, r"SYSTEM\CurrentControlSet\Control\FileSystem"
|
|
2799
|
+
) as key:
|
|
2800
|
+
value, _ = winreg.QueryValueEx(key, "LongPathsEnabled")
|
|
2801
|
+
return bool(value)
|
|
2802
|
+
except FileNotFoundError:
|
|
2803
|
+
return False
|
|
2804
|
+
except OSError:
|
|
2805
|
+
return None
|
|
2806
|
+
|
|
2807
|
+
|
|
2808
|
+
#: Where the `arm-zephyr-eabi` cross compiler sits INSIDE a zephyr-sdk-1.0.1
|
|
2809
|
+
#: root -- the version `ZEPHYR_SDK_INSTALL_VERSION` above pins and the only
|
|
2810
|
+
#: one this file's fix hints (`zephyr_sdk_install_command`) ever name.
|
|
2811
|
+
#:
|
|
2812
|
+
#: tan-cli#286 third pass: the SECOND pass's blocker. `_zephyr_sdk_root_valid`
|
|
2813
|
+
#: and `test_doctor_command.py`'s own `_plant_zephyr_sdk` fixture both
|
|
2814
|
+
#: previously hardcoded the WRONG layout (un-prefixed `arm-zephyr-eabi/bin/`)
|
|
2815
|
+
#: independently, so they agreed with EACH OTHER instead of with a real SDK
|
|
2816
|
+
#: and 77 tests passed over a broken probe. Both now build from this one
|
|
2817
|
+
#: tuple so they cannot drift back to silently matching only each other.
|
|
2818
|
+
#:
|
|
2819
|
+
#: The `gnu/` prefix is decisive, not guessed: a maintainer build log on the
|
|
2820
|
+
#: exact host this check hard-failed on -- "Found assembler:
|
|
2821
|
+
#: <home>/zephyr-sdk-1.0.1/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gcc.exe"
|
|
2822
|
+
#: -- plus three in-repo measurements agreeing byte-for-byte:
|
|
2823
|
+
#: `crates/tan-core/src/runners.rs`'s real-AEN801-build fixture (`gdb:
|
|
2824
|
+
#: /zephyr-sdk-1.0.1/gnu/arm-zephyr-eabi/bin/arm-zephyr-eabi-gdb-py`),
|
|
2825
|
+
#: `crates/tan-core/src/debug_launch.rs`'s resolution test (same `gdbPath`),
|
|
2826
|
+
#: and `contract/fixtures/toolchains/toolchains.json`'s `du -sb` measurement
|
|
2827
|
+
#: of `gnu/arm-zephyr-eabi/` (784086497 bytes) as its own line item, separate
|
|
2828
|
+
#: from `hosttools/`.
|
|
2829
|
+
#:
|
|
2830
|
+
#: NOT widened to also accept the older, un-prefixed `arm-zephyr-eabi/bin/`
|
|
2831
|
+
#: layout (0.16.x): every fix hint in this file already promises exactly
|
|
2832
|
+
#: `--version 1.0.1`, so treating a stale sub-1.0 install as a Pass would
|
|
2833
|
+
#: validate a toolchain this file's own advice says to replace. NOT probing
|
|
2834
|
+
#: the SDK's own `sdk_version`/`sdk_toolchains` marker files either, tempting
|
|
2835
|
+
#: as a layout-proof alternative would be: no measurement of either file's
|
|
2836
|
+
#: real name, location or format exists anywhere in this repo, and guessing
|
|
2837
|
+
#: at one is the exact unverified-brief mistake that put the wrong compiler
|
|
2838
|
+
#: path here to begin with.
|
|
2839
|
+
ZEPHYR_SDK_TOOLCHAIN_DIR = ("gnu", "arm-zephyr-eabi", "bin")
|
|
2840
|
+
|
|
2841
|
+
|
|
2842
|
+
def _zephyr_sdk_root_valid(root: Path) -> bool:
|
|
2843
|
+
"""`True` when `root` is an actually-installed Zephyr SDK -- not merely a
|
|
2844
|
+
directory that happens to be named right, or still named by a stale
|
|
2845
|
+
`ZEPHYR_SDK_INSTALL_DIR`. Probes the one file every downstream check
|
|
2846
|
+
(`west build`, `west flash`) actually needs: the `arm-zephyr-eabi` cross
|
|
2847
|
+
compiler, at `ZEPHYR_SDK_TOOLCHAIN_DIR`. `is_dir()` alone passes on an
|
|
2848
|
+
EMPTY directory -- the exact false Pass tan-cli#286 exists to fix;
|
|
2849
|
+
measuring the shipped thing instead of a directory-name proxy is what
|
|
2850
|
+
makes this port's docstring true.
|
|
2851
|
+
"""
|
|
2852
|
+
exe = "arm-zephyr-eabi-gcc.exe" if os.name == "nt" else "arm-zephyr-eabi-gcc"
|
|
2853
|
+
try:
|
|
2854
|
+
return root.joinpath(*ZEPHYR_SDK_TOOLCHAIN_DIR, exe).is_file()
|
|
2855
|
+
except OSError:
|
|
2856
|
+
return False
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def _zephyr_sdk_scan_roots() -> list[Path]:
|
|
2860
|
+
"""Every directory `_zephyr_sdk_detected` scans for a `zephyr-sdk-*`
|
|
2861
|
+
install, besides `/opt` -- `$HOME`, `%USERPROFILE%` AND `Path.home()`,
|
|
2862
|
+
ALL of them, never `HOME or USERPROFILE` -- PLUS the ADR 0021
|
|
2863
|
+
artifact-keyed store (`~/.alp/toolchains`, or `$ALP_TOOLCHAIN_ROOT`)
|
|
2864
|
+
`tan bootstrap`'s own toolchain phase installs into, two directory
|
|
2865
|
+
levels below `$HOME` and invisible to the first four roots (tan-cli#1186:
|
|
2866
|
+
a real compiler planted at that exact bootstrap-filled path was measured
|
|
2867
|
+
returning `False` here before this root was added).
|
|
2868
|
+
`_toolchain_store_scan_root`/`_is_toolchain_wreckage` are reused from
|
|
2869
|
+
`build.toolchain` rather than re-derived -- the SAME store root
|
|
2870
|
+
`${TOOLCHAIN_ROOT}` substitution (tan-cli#547) already scans, so this
|
|
2871
|
+
check and that one cannot independently drift on where tan's own
|
|
2872
|
+
installs live, and the SAME `.tmp-<pid>` wreckage exclusion applies (an
|
|
2873
|
+
interrupted acquisition's leftover also starts with `zephyr-sdk`). Routed
|
|
2874
|
+
through the same string-level `seen` dedup as the other four roots (an
|
|
2875
|
+
`ALP_TOOLCHAIN_ROOT` pointed at `$HOME` or `/opt` -- both legitimate,
|
|
2876
|
+
e.g. a bench-machine escape hatch -- would otherwise list that directory
|
|
2877
|
+
twice; harmless for this yes/no scan but not for the invariant the other
|
|
2878
|
+
roots already hold).
|
|
2879
|
+
|
|
2880
|
+
Under Git Bash/MSYS on Windows, `HOME` is a POSIX-translated path
|
|
2881
|
+
(`/c/Users/dev`) while the real Zephyr SDK sits under the native
|
|
2882
|
+
`%USERPROFILE%` (`C:\\Users\\dev\\zephyr-sdk-1.0.1`). `or`ing the two
|
|
2883
|
+
picks whichever is set first and silently drops the other -- proven on a
|
|
2884
|
+
real host: that host HAS the SDK and `_zephyr_sdk_detected()` still
|
|
2885
|
+
returned `False`, a hard doctor FAIL worse than the false PASS #286
|
|
2886
|
+
exists to fix. `Path.home()` resolves independently of both env vars
|
|
2887
|
+
(POSIX `pwd`/`$HOME`; Windows `USERPROFILE` via CPython's own
|
|
2888
|
+
`ntpath.expanduser`) and can disagree with both, so it is scanned too,
|
|
2889
|
+
not assumed redundant.
|
|
2890
|
+
"""
|
|
2891
|
+
roots = [Path("/opt")]
|
|
2892
|
+
seen: set[str] = {str(roots[0])}
|
|
2893
|
+
for raw in (os.environ.get("HOME"), os.environ.get("USERPROFILE")):
|
|
2894
|
+
if raw and raw not in seen:
|
|
2895
|
+
seen.add(raw)
|
|
2896
|
+
roots.append(Path(raw))
|
|
2897
|
+
try:
|
|
2898
|
+
home = Path.home()
|
|
2899
|
+
except (OSError, RuntimeError):
|
|
2900
|
+
home = None
|
|
2901
|
+
if home is not None and str(home) not in seen:
|
|
2902
|
+
roots.append(home)
|
|
2903
|
+
seen.add(str(home))
|
|
2904
|
+
store_root = _toolchain_store_scan_root()
|
|
2905
|
+
if str(store_root) not in seen:
|
|
2906
|
+
roots.append(store_root)
|
|
2907
|
+
return roots
|
|
2908
|
+
|
|
2909
|
+
|
|
2910
|
+
def _zephyr_sdk_detected_roots() -> list[Path]:
|
|
2911
|
+
"""EVERY root the scan validates, in precedence order (tan-cli#1483: the
|
|
2912
|
+
adoption check needs to see all of them, not just the first -- a
|
|
2913
|
+
mismatched `ZEPHYR_SDK_INSTALL_DIR` must not hide an installed copy of the
|
|
2914
|
+
pinned version). `ZEPHYR_SDK_INSTALL_DIR` first, then each `zephyr-sdk*`
|
|
2915
|
+
entry under `_zephyr_sdk_scan_roots()`.
|
|
2916
|
+
|
|
2917
|
+
Never raises: an unreadable or missing scan root is "nothing found
|
|
2918
|
+
there", not a doctor crash.
|
|
2919
|
+
"""
|
|
2920
|
+
found: list[Path] = []
|
|
2921
|
+
env_dir = os.environ.get("ZEPHYR_SDK_INSTALL_DIR")
|
|
2922
|
+
if env_dir and _zephyr_sdk_root_valid(Path(env_dir)):
|
|
2923
|
+
found.append(Path(env_dir))
|
|
2924
|
+
for root in _zephyr_sdk_scan_roots():
|
|
2925
|
+
try:
|
|
2926
|
+
entries = sorted(root.iterdir())
|
|
2927
|
+
except OSError:
|
|
2928
|
+
continue
|
|
2929
|
+
for entry in entries:
|
|
2930
|
+
if (
|
|
2931
|
+
entry.name.startswith("zephyr-sdk")
|
|
2932
|
+
and not _is_toolchain_wreckage(entry.name)
|
|
2933
|
+
and _zephyr_sdk_root_valid(entry)
|
|
2934
|
+
):
|
|
2935
|
+
found.append(entry)
|
|
2936
|
+
return found
|
|
2937
|
+
|
|
2938
|
+
|
|
2939
|
+
def _zephyr_sdk_detected_root() -> Path | None:
|
|
2940
|
+
"""The first root `_zephyr_sdk_detected_roots` validates, or `None`."""
|
|
2941
|
+
roots = _zephyr_sdk_detected_roots()
|
|
2942
|
+
return roots[0] if roots else None
|
|
2943
|
+
|
|
2944
|
+
|
|
2945
|
+
def _zephyr_sdk_detected() -> bool:
|
|
2946
|
+
"""`True` when a Zephyr SDK toolchain is installed anywhere this host
|
|
2947
|
+
would resolve one from. Descends from `crate::toolchain::
|
|
2948
|
+
resolve_toolchain_root`/`zephyr_sdk_detected` in the now-deleted Rust
|
|
2949
|
+
oracle (`crates/` retired in tan-cli#601) -- doctor only ever needed that
|
|
2950
|
+
pair's yes/no half. Build-plan `${TOOLCHAIN_ROOT}` substitution is its own
|
|
2951
|
+
resolver now, not `None` as it was when this docstring was last true:
|
|
2952
|
+
`build.toolchain.resolve_toolchain_root` (tan-cli#547), which
|
|
2953
|
+
`build_cmd.py` calls and passes on as `toolchain_root=toolchain.root`.
|
|
2954
|
+
That resolver is stricter on purpose -- a host with SEVERAL candidate
|
|
2955
|
+
installs and no `ZEPHYR_SDK_INSTALL_DIR` to choose between them must FAIL
|
|
2956
|
+
a substitution (the wrong one silently baked into a slice is worse than
|
|
2957
|
+
refusing), but should not fail doctor's plain yes/no here, which only
|
|
2958
|
+
needs to know that AT LEAST one usable toolchain exists somewhere:
|
|
2959
|
+
`ZEPHYR_SDK_INSTALL_DIR`, honored ONLY when the directory it names
|
|
2960
|
+
actually CONTAINS the toolchain (`_zephyr_sdk_root_valid` -- the variable
|
|
2961
|
+
is exported from a shell profile and routinely outlives the SDK it once
|
|
2962
|
+
pointed at, e.g. after `rm -rf ~/zephyr-sdk-0.16.5`, and an empty
|
|
2963
|
+
directory it never pointed at anything real for is the same failure mode
|
|
2964
|
+
-- trusting presence alone would report a false Pass here and the real
|
|
2965
|
+
failure would surface later as a raw CMake toolchain error); else any
|
|
2966
|
+
`zephyr-sdk*`-named directory, similarly validated, directly under
|
|
2967
|
+
`_zephyr_sdk_scan_roots()` -- which now also covers the ADR 0021
|
|
2968
|
+
artifact-keyed store `tan bootstrap` fills (tan-cli#1186). Several
|
|
2969
|
+
installs still count as detected here -- this is only doctor's yes/no,
|
|
2970
|
+
not the ambiguous-root pick `${TOOLCHAIN_ROOT}` substitution needs.
|
|
2971
|
+
|
|
2972
|
+
Never raises: an unreadable or missing scan root is "nothing found
|
|
2973
|
+
there", not a doctor crash. Delegates to `_zephyr_sdk_detected_root` so
|
|
2974
|
+
the two cannot independently drift on what "detected" means.
|
|
2975
|
+
"""
|
|
2976
|
+
return _zephyr_sdk_detected_root() is not None
|
|
2977
|
+
|
|
2978
|
+
|
|
2979
|
+
def _resolve_dtc() -> devicetree_lint.DtcResolution:
|
|
2980
|
+
"""Resolve `dtc` the way `zephyr/cmake/modules/FindDtc.cmake` would, and
|
|
2981
|
+
ask it its version -- the IO half of `devicetree_lint_check`.
|
|
2982
|
+
|
|
2983
|
+
Order matters and is CMake's, not this file's: `find_program` searches
|
|
2984
|
+
`CMAKE_PREFIX_PATH` BEFORE the ambient `PATH`, and the SDK's own
|
|
2985
|
+
`cmake/zephyr/host-tools.cmake` is the only thing that puts a
|
|
2986
|
+
`dtc`-bearing prefix on that list. A PATH-first probe would attribute the
|
|
2987
|
+
reported version to the wrong binary on a host that has both -- the same
|
|
2988
|
+
"which binary answered" defect tan-cli#123/#488 closed for `west`.
|
|
2989
|
+
|
|
2990
|
+
The SDK root comes from `_zephyr_sdk_detected_root()` -- the SAME
|
|
2991
|
+
`ZEPHYR_SDK_INSTALL_DIR`-then-scan precedence `zephyrSdk` and `toolchain`
|
|
2992
|
+
already trust, reused rather than re-rolled. One narrowing, stated rather
|
|
2993
|
+
than glossed: an SDK reachable ONLY through CMake's user package registry
|
|
2994
|
+
(`~/.cmake/packages/Zephyr-sdk/*`, which `west sdk install` writes and
|
|
2995
|
+
`FindZephyr-sdk.cmake`'s CONFIG-mode lookup consults) and living outside
|
|
2996
|
+
`$HOME` and `/opt` is not consulted here. That can only ever move a hosttools
|
|
2997
|
+
`dtc` from the first arm to the third -- `pass` either way -- and never
|
|
2998
|
+
manufacture the `warn`, which is the arm that would cost a customer
|
|
2999
|
+
attention.
|
|
3000
|
+
|
|
3001
|
+
Never raises: `_zephyr_sdk_detected_root`, `on_path` and `probe` are all
|
|
3002
|
+
non-raising by contract (see the module docstring), and the one direct
|
|
3003
|
+
filesystem touch here catches `OSError` itself.
|
|
3004
|
+
"""
|
|
3005
|
+
host_os, host_arch = _host_os_arch_tags()
|
|
3006
|
+
found: str | None = None
|
|
3007
|
+
origin = devicetree_lint.ORIGIN_ABSENT
|
|
3008
|
+
sdk_dir = _zephyr_sdk_detected_root()
|
|
3009
|
+
if sdk_dir is not None:
|
|
3010
|
+
bin_dir = devicetree_lint.hosttools_bin_dir(sdk_dir, host_os, host_arch)
|
|
3011
|
+
if bin_dir is not None:
|
|
3012
|
+
candidate = bin_dir / ("dtc.exe" if os.name == "nt" else "dtc")
|
|
3013
|
+
try:
|
|
3014
|
+
present = candidate.is_file()
|
|
3015
|
+
except OSError:
|
|
3016
|
+
present = False
|
|
3017
|
+
if present:
|
|
3018
|
+
found, origin = str(candidate), devicetree_lint.ORIGIN_HOSTTOOLS
|
|
3019
|
+
if found is None:
|
|
3020
|
+
on_path_dtc = on_path("dtc")
|
|
3021
|
+
if on_path_dtc is not None:
|
|
3022
|
+
found, origin = on_path_dtc, devicetree_lint.ORIGIN_PATH
|
|
3023
|
+
version = (
|
|
3024
|
+
devicetree_lint.parse_dtc_version(probe([found, "--version"]))
|
|
3025
|
+
if found is not None
|
|
3026
|
+
else None
|
|
3027
|
+
)
|
|
3028
|
+
return devicetree_lint.DtcResolution(found, origin, version)
|
|
3029
|
+
|
|
3030
|
+
|
|
3031
|
+
def _probe_host_python(
|
|
3032
|
+
floor: tuple[int, int], candidates: list | None = None
|
|
3033
|
+
) -> tuple[str, tuple[int, int], str] | None:
|
|
3034
|
+
"""First candidate that RUNS and clears `floor`; else the first that merely
|
|
3035
|
+
ran, so the too-old message can name a real version instead of "did not
|
|
3036
|
+
run". Mirrors `crate::util::probe_host_python`.
|
|
3037
|
+
|
|
3038
|
+
tan-cli#797: `candidate[0]` (`"py"`/`"python"`/`"python3"`) is resolved
|
|
3039
|
+
through `tool_lookup.resolve_tool` and spawned as `executable=`, keeping
|
|
3040
|
+
`candidate` itself -- and therefore `entry`'s display spelling -- bare. A
|
|
3041
|
+
candidate that does not resolve is skipped outright, the same "no answer"
|
|
3042
|
+
treatment `probe()` already gives every other way this can fail; it is
|
|
3043
|
+
never handed to `subprocess` unresolved.
|
|
3044
|
+
|
|
3045
|
+
The returned 3-tuple carries the RESOLVED path as its third element
|
|
3046
|
+
alongside the display spelling and version, so a caller that needs to
|
|
3047
|
+
spawn this same interpreter again (`_posix_venv_capable`'s `ensurepip`
|
|
3048
|
+
probe) threads the already-resolved path through as `executable=` instead
|
|
3049
|
+
of re-deriving argv from the display spelling and handing THAT bare to
|
|
3050
|
+
`subprocess` -- the resolve-then-spawn-something-else divergence
|
|
3051
|
+
tan-cli#797 exists to close. `python_check` only reads the first two
|
|
3052
|
+
elements; a caller wanting just those may slice `found[:2]`.
|
|
3053
|
+
"""
|
|
3054
|
+
if candidates is None:
|
|
3055
|
+
candidates = probe_all_host_pythons()
|
|
3056
|
+
found = select_host_python(candidates, floor)
|
|
3057
|
+
return None if found is None else (found.display, found.version, found.resolved)
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
@dataclass(frozen=True)
|
|
3061
|
+
class ManifestLoad:
|
|
3062
|
+
"""The result of resolving `<sdk>/metadata/bootstrap.json`.
|
|
3063
|
+
|
|
3064
|
+
`is_real` is the provenance verdict as DATA, set exactly once, at the one
|
|
3065
|
+
return that actually read and parsed a manifest -- never re-derived by a
|
|
3066
|
+
caller sniffing `source`'s prose. `source` is still carried for display
|
|
3067
|
+
(the message names WHICH file or fallback), but nothing downstream may
|
|
3068
|
+
infer `is_real` from it: that used to be `source.startswith("facts from
|
|
3069
|
+
alp-sdk")`, which silently flips the verdict the moment this docstring's
|
|
3070
|
+
or `source`'s wording changes, with nothing to catch it.
|
|
3071
|
+
"""
|
|
3072
|
+
|
|
3073
|
+
facts: dict
|
|
3074
|
+
source: str
|
|
3075
|
+
error: str | None
|
|
3076
|
+
is_real: bool
|
|
3077
|
+
#: tan-cli#1066 review: what was unreadable inside a manifest that was
|
|
3078
|
+
#: otherwise read FINE -- today only the `artifactProvenance` block. A
|
|
3079
|
+
#: SECOND field rather than reusing `error`, because the two mean opposite
|
|
3080
|
+
#: things to the caller: `error` says "this manifest was rejected and the
|
|
3081
|
+
#: built-in fallback list is in play", which would be false here (the tool
|
|
3082
|
+
#: list, the floor and the install commands were all read normally, and
|
|
3083
|
+
#: `is_real` stays true). Both raise the same `bootstrapManifest` warn and
|
|
3084
|
+
#: the same `doctor.bootstrap-manifest` issue -- they differ only in what
|
|
3085
|
+
#: the line says, and saying the wrong one would send a customer hunting a
|
|
3086
|
+
#: prerequisite list that is not actually stale.
|
|
3087
|
+
provenance_error: str | None = None
|
|
3088
|
+
|
|
3089
|
+
|
|
3090
|
+
def _load_manifest(sdk_root: str | None) -> ManifestLoad:
|
|
3091
|
+
"""Resolve the prerequisites facts from `<sdk>/metadata/bootstrap.json`.
|
|
3092
|
+
|
|
3093
|
+
A missing or malformed manifest is a WARNING with documented fallbacks, not
|
|
3094
|
+
a refusal: doctor's whole job is to run on a host where things are wrong,
|
|
3095
|
+
and a doctor that cannot start because the thing it diagnoses is broken is
|
|
3096
|
+
the failure mode it exists to prevent.
|
|
3097
|
+
"""
|
|
3098
|
+
fallback = {
|
|
3099
|
+
"posix": ["git", "cmake", "python3", "ninja"],
|
|
3100
|
+
"windows": ["git", "cmake", "python", "ninja"],
|
|
3101
|
+
"pythonMinVersion": f"{FALLBACK_PYTHON_FLOOR[0]}.{FALLBACK_PYTHON_FLOOR[1]}",
|
|
3102
|
+
"install": {},
|
|
3103
|
+
}
|
|
3104
|
+
if sdk_root is None:
|
|
3105
|
+
return ManifestLoad(
|
|
3106
|
+
fallback,
|
|
3107
|
+
"tan's built-in fallback list (no alp-sdk checkout resolved)",
|
|
3108
|
+
None,
|
|
3109
|
+
is_real=False,
|
|
3110
|
+
)
|
|
3111
|
+
path = Path(sdk_root) / "metadata" / "bootstrap.json"
|
|
3112
|
+
text = _read_text(path)
|
|
3113
|
+
if text is None:
|
|
3114
|
+
return ManifestLoad(
|
|
3115
|
+
fallback,
|
|
3116
|
+
"tan's built-in fallback list",
|
|
3117
|
+
f"could not read {path}",
|
|
3118
|
+
is_real=False,
|
|
3119
|
+
)
|
|
3120
|
+
try:
|
|
3121
|
+
facts = json.loads(text)
|
|
3122
|
+
except ValueError as err:
|
|
3123
|
+
return ManifestLoad(
|
|
3124
|
+
fallback, "tan's built-in fallback list", f"{path} is not valid JSON: {err}", is_real=False
|
|
3125
|
+
)
|
|
3126
|
+
if not isinstance(facts, dict):
|
|
3127
|
+
return ManifestLoad(
|
|
3128
|
+
fallback, "tan's built-in fallback list", f"{path} is not a JSON object", is_real=False
|
|
3129
|
+
)
|
|
3130
|
+
# tan-cli#488 defect 3: `schemaVersion` read on its own FIRST, mirroring
|
|
3131
|
+
# `tan.core.bootstrap.parse_bootstrap_manifest` -- the ONE other reader of
|
|
3132
|
+
# this same file -- so the two commands agree about what a future,
|
|
3133
|
+
# reshaped manifest means. Before this, `_load_manifest` accepted ANY
|
|
3134
|
+
# `prerequisites` object regardless of `schemaVersion`, so an SDK whose
|
|
3135
|
+
# manifest `tan bootstrap` refuses outright (a schema this build does not
|
|
3136
|
+
# understand) still yielded `hostPrerequisites: pass`, zero tools probed,
|
|
3137
|
+
# and no `bootstrapManifest` warning -- a clean bill of health for a
|
|
3138
|
+
# manifest that was never actually read. `bool` excluded explicitly, same
|
|
3139
|
+
# reason `parse_bootstrap_manifest` excludes it: `True == 1` in Python.
|
|
3140
|
+
schema_version = facts.get("schemaVersion")
|
|
3141
|
+
if not isinstance(schema_version, int) or isinstance(schema_version, bool):
|
|
3142
|
+
return ManifestLoad(
|
|
3143
|
+
fallback,
|
|
3144
|
+
"tan's built-in fallback list",
|
|
3145
|
+
f"{path} could not be read: missing `schemaVersion`",
|
|
3146
|
+
is_real=False,
|
|
3147
|
+
)
|
|
3148
|
+
if schema_version != BOOTSTRAP_MANIFEST_SCHEMA_VERSION:
|
|
3149
|
+
return ManifestLoad(
|
|
3150
|
+
fallback,
|
|
3151
|
+
"tan's built-in fallback list",
|
|
3152
|
+
# tan-cli#488 defect 2: no trailing period -- this string is
|
|
3153
|
+
# always read back through `f"... rejected: {loaded.error}. Falling
|
|
3154
|
+
# back to ..."` (below), which supplies its own; a period here
|
|
3155
|
+
# rendered "...outright.. Falling back to" (double stop).
|
|
3156
|
+
f"{path} declares schemaVersion {schema_version}, but this `tan` "
|
|
3157
|
+
f"supports only {BOOTSTRAP_MANIFEST_SCHEMA_VERSION}. Update `tan`, or "
|
|
3158
|
+
f"pin an SDK whose metadata/bootstrap.json this version understands; "
|
|
3159
|
+
f"`tan bootstrap` already refuses this manifest outright",
|
|
3160
|
+
is_real=False,
|
|
3161
|
+
)
|
|
3162
|
+
prerequisites = facts.get("prerequisites")
|
|
3163
|
+
if not isinstance(prerequisites, dict):
|
|
3164
|
+
return ManifestLoad(
|
|
3165
|
+
fallback,
|
|
3166
|
+
"tan's built-in fallback list",
|
|
3167
|
+
f"{path} has no `prerequisites` object",
|
|
3168
|
+
is_real=False,
|
|
3169
|
+
)
|
|
3170
|
+
west = facts.get("west")
|
|
3171
|
+
if isinstance(west, dict):
|
|
3172
|
+
prerequisites = {**prerequisites, "_pipSpec": west.get("pipSpec")}
|
|
3173
|
+
# tan-cli#606: `zephyr.pythonMinVersion` is a SEPARATE, Zephyr-scoped
|
|
3174
|
+
# floor from `prerequisites.pythonMinVersion` above (alp-sdk#1078) -- not
|
|
3175
|
+
# a duplicate of it. Injected onto the returned `prerequisites` dict the
|
|
3176
|
+
# same way `_pipSpec` is, so `_zephyr_manifest_floor_from_facts` has one
|
|
3177
|
+
# place to read it from without a second manifest parse.
|
|
3178
|
+
zephyr = facts.get("zephyr")
|
|
3179
|
+
if isinstance(zephyr, dict) and isinstance(zephyr.get("pythonMinVersion"), str):
|
|
3180
|
+
prerequisites = {
|
|
3181
|
+
**prerequisites,
|
|
3182
|
+
"_zephyrPythonMinVersion": zephyr["pythonMinVersion"],
|
|
3183
|
+
}
|
|
3184
|
+
# tan-cli#1066: `artifactProvenance` is TOP-LEVEL in the manifest
|
|
3185
|
+
# (alp-sdk#1574) while everything else this reader returns lives under
|
|
3186
|
+
# `prerequisites` -- injected onto the returned dict the same way
|
|
3187
|
+
# `_pipSpec` and `_zephyrPythonMinVersion` are, so `prerequisites_check`
|
|
3188
|
+
# has one place to read it from and this file keeps its single parse.
|
|
3189
|
+
# ALWAYS injected, even when absent/malformed: `parse_table` answers `{}`
|
|
3190
|
+
# for both, and `{}` is the same "nothing reported" a tool with no entry
|
|
3191
|
+
# gets -- so no caller has to branch on whether the key was there.
|
|
3192
|
+
prerequisites = {
|
|
3193
|
+
**prerequisites,
|
|
3194
|
+
"_artifactProvenance": artifact_provenance.parse_table(
|
|
3195
|
+
facts.get(artifact_provenance.BLOCK_KEY)
|
|
3196
|
+
),
|
|
3197
|
+
}
|
|
3198
|
+
return ManifestLoad(
|
|
3199
|
+
prerequisites,
|
|
3200
|
+
f"facts from alp-sdk {path}",
|
|
3201
|
+
None,
|
|
3202
|
+
is_real=True,
|
|
3203
|
+
# Reported, never refused: a corrupt provenance block must not cost
|
|
3204
|
+
# this customer their diagnosis (tan-cli#1066), and must not cost the
|
|
3205
|
+
# NEXT one the ability to see that alp-sdk's generator regressed
|
|
3206
|
+
# (#1066 review -- a silent degrade is byte-identical to a pre-v0.16.0
|
|
3207
|
+
# SDK, so the producer bug is undetectable from here).
|
|
3208
|
+
provenance_error=artifact_provenance.problems_in(facts),
|
|
3209
|
+
)
|
|
3210
|
+
|
|
3211
|
+
|
|
3212
|
+
def _manifest_floor_from_facts(facts: dict) -> tuple[int, int]:
|
|
3213
|
+
"""The `pythonMinVersion` `facts` declares, or `FALLBACK_PYTHON_FLOOR` when
|
|
3214
|
+
absent/unparseable -- shared by `_collect` and `resolve_manifest_python_floor`
|
|
3215
|
+
so the two never parse the same field two different ways."""
|
|
3216
|
+
return _parse_two(str(facts.get("pythonMinVersion") or "")) or FALLBACK_PYTHON_FLOOR
|
|
3217
|
+
|
|
3218
|
+
|
|
3219
|
+
def _zephyr_manifest_floor_from_facts(facts: dict) -> tuple[int, int] | None:
|
|
3220
|
+
"""The `zephyr.pythonMinVersion` `facts` declares (tan-cli#606), or `None`
|
|
3221
|
+
when absent/unparseable/no manifest resolved at all -- callers fall back
|
|
3222
|
+
to `ZEPHYR_PYTHON_FLOOR` in that case, same as before this field existed.
|
|
3223
|
+
Unlike `_manifest_floor_from_facts`, `None` is a normal result, not a
|
|
3224
|
+
degraded one: most manifests on disk today predate this key."""
|
|
3225
|
+
raw = facts.get("_zephyrPythonMinVersion")
|
|
3226
|
+
if not isinstance(raw, str):
|
|
3227
|
+
return None
|
|
3228
|
+
return _parse_two(raw)
|
|
3229
|
+
|
|
3230
|
+
|
|
3231
|
+
def resolve_manifest_python_floor(sdk_root: str | None) -> tuple[tuple[int, int], str]:
|
|
3232
|
+
"""`(floor, provenance)` for the SDK's OWN declared Python floor --
|
|
3233
|
+
`<sdk>/metadata/bootstrap.json`'s `prerequisites.pythonMinVersion` -- for
|
|
3234
|
+
callers gating a SPAWNED SDK interpreter (`generate`/`model`) rather than a
|
|
3235
|
+
Zephyr build, so they want this floor, not `_collect`'s Zephyr-composed
|
|
3236
|
+
effective one. The ONE reader: before this, `generate_cmd` and `model_cmd`
|
|
3237
|
+
each carried their own hardcoded `MIN_PYTHON = (3, 10)`, a floor that could
|
|
3238
|
+
drift from the manifest's -- and from each other's -- without either
|
|
3239
|
+
command noticing.
|
|
3240
|
+
"""
|
|
3241
|
+
loaded = _load_manifest(sdk_root)
|
|
3242
|
+
return _manifest_floor_from_facts(loaded.facts), loaded.source
|
|
3243
|
+
|
|
3244
|
+
|
|
3245
|
+
#: Generous on purpose: a real install can pull a package over the network,
|
|
3246
|
+
#: unlike every OTHER timeout in this file (`PROBE_TIMEOUT_S`), which only
|
|
3247
|
+
#: ever waits on a local `--version` banner. `ponytail`: one fixed ceiling,
|
|
3248
|
+
#: no live progress WITHIN a single install -- `run_fix`'s `on_check` streams
|
|
3249
|
+
#: a Check the MOMENT one tool's own outcome is known, which closes the blank
|
|
3250
|
+
#: window BETWEEN tools, not the window a single slow install itself still
|
|
3251
|
+
#: opens; raise this ceiling, or stream that install's own output, if one
|
|
3252
|
+
#: exceeds it before this is revisited.
|
|
3253
|
+
FIX_INSTALL_TIMEOUT_S = 300
|
|
3254
|
+
|
|
3255
|
+
|
|
3256
|
+
def fix_needs_sudo_check(tool: str, command: str) -> Check:
|
|
3257
|
+
"""`doctor.fix-needs-sudo` -- ADR 0021's Tier-B refusal (tan-cli#91,
|
|
3258
|
+
MAINTAINER DECISION): tan never spawns the `sudo` PROGRAM on the
|
|
3259
|
+
customer's behalf.
|
|
3260
|
+
|
|
3261
|
+
Under `--format json` this process's stdio is captured end to end, so a
|
|
3262
|
+
`sudo` password prompt has nowhere to go -- it would hang forever rather
|
|
3263
|
+
than fail loudly, which is a worse outcome than refusing up front. REFUSE
|
|
3264
|
+
AND PRINT: name the exact command, verbatim, so it can be pasted into a
|
|
3265
|
+
real terminal, and stop there. `run_fix` below is the only caller, and
|
|
3266
|
+
only reaches this branch for a command whose first word IS literally
|
|
3267
|
+
`sudo` -- the manifest's own POSIX `prerequisites.install` commands are
|
|
3268
|
+
the one place that word appears in this codebase at all; Windows
|
|
3269
|
+
(`winget`, user-scope) and macOS (`brew`) never need it.
|
|
3270
|
+
|
|
3271
|
+
tan-cli#650: `run_fix` only reaches THIS branch when the caller is NOT
|
|
3272
|
+
already root -- a root caller has nothing left to elevate to, and
|
|
3273
|
+
`run_fix` strips the literal `sudo ` word and runs the manifest's own
|
|
3274
|
+
remaining command directly instead of calling this function at all. That
|
|
3275
|
+
still never spawns the `sudo` program (the MAINTAINER DECISION above is
|
|
3276
|
+
about the program, not the elevation it grants), so a root caller getting
|
|
3277
|
+
real remediation here does not contradict it -- it recognises that
|
|
3278
|
+
"needs elevation" is already satisfied when the process asking is root.
|
|
3279
|
+
"""
|
|
3280
|
+
return Check(
|
|
3281
|
+
f"fix:{tool}",
|
|
3282
|
+
"warn",
|
|
3283
|
+
f'`--fix` will not run `{command}` for {tool}: it needs elevation '
|
|
3284
|
+
f'("sudo"), and tan never spawns sudo itself. Run it yourself, then '
|
|
3285
|
+
f"re-run `tan doctor`.",
|
|
3286
|
+
command,
|
|
3287
|
+
code="doctor.fix-needs-sudo",
|
|
3288
|
+
scope="host",
|
|
3289
|
+
)
|
|
3290
|
+
|
|
3291
|
+
|
|
3292
|
+
def fix_installed_check(
|
|
3293
|
+
tool: str, command: str, *, elevation_skipped: bool = False
|
|
3294
|
+
) -> Check:
|
|
3295
|
+
"""`doctor.fix-installed` -- `--fix` ran a manifest install command that
|
|
3296
|
+
needed no elevation (ADR 0021 Tier A), and the child process exited 0.
|
|
3297
|
+
|
|
3298
|
+
Deliberately NOT a claim that `{tool}` is now on PATH: this process
|
|
3299
|
+
already read its own PATH at start-up (tan-cli#91), so an install that
|
|
3300
|
+
lands after that moment is invisible to it -- there is no same-process
|
|
3301
|
+
re-check to perform, honestly or otherwise. "Installed; reopen your
|
|
3302
|
+
shell" is the whole truth this check can tell; `hostPrerequisites`
|
|
3303
|
+
above still reports `{tool}` missing in THIS report, which is correct
|
|
3304
|
+
for THIS report.
|
|
3305
|
+
|
|
3306
|
+
`elevation_skipped` (tan-cli#650): set when `command` is the manifest's
|
|
3307
|
+
own line with its literal `sudo ` prefix already removed by `run_fix`,
|
|
3308
|
+
because the caller was ALREADY root -- there was no elevation left to
|
|
3309
|
+
acquire. Said explicitly so this reads as the root-aware Tier-B path it
|
|
3310
|
+
is, not as an ordinary Tier-A ("this tool never needed elevation at
|
|
3311
|
+
all") outcome; the two are true for different reasons and a reader
|
|
3312
|
+
deciding whether to trust `--fix` on their next root container should be
|
|
3313
|
+
able to tell them apart.
|
|
3314
|
+
"""
|
|
3315
|
+
lede = (
|
|
3316
|
+
f"`--fix` ran `{command}` for {tool} (already running as root, so "
|
|
3317
|
+
f"the manifest's `sudo` was not needed and was not run)."
|
|
3318
|
+
if elevation_skipped
|
|
3319
|
+
else f"`--fix` ran `{command}` for {tool}."
|
|
3320
|
+
)
|
|
3321
|
+
return Check(
|
|
3322
|
+
f"fix:{tool}",
|
|
3323
|
+
"warn",
|
|
3324
|
+
f"{lede} tan cannot see a PATH change made after it started -- open "
|
|
3325
|
+
f"a new shell, then re-run `tan doctor` there to confirm.",
|
|
3326
|
+
code="doctor.fix-installed",
|
|
3327
|
+
scope="host",
|
|
3328
|
+
)
|
|
3329
|
+
|
|
3330
|
+
|
|
3331
|
+
def fix_spawn_failed_check(tool: str, command: str, err: Exception) -> Check:
|
|
3332
|
+
"""`doctor.fix-spawn-failed` -- `--fix` resolved `{tool}`'s install
|
|
3333
|
+
command on PATH (`on_path` already succeeded) but starting it raised
|
|
3334
|
+
(`OSError`/`ValueError`/`subprocess.SubprocessError` other than a
|
|
3335
|
+
timeout). Distinct from silence: without this, a customer watching
|
|
3336
|
+
`--fix` do nothing cannot tell "the OS refused to start it" from "tan
|
|
3337
|
+
never tried"."""
|
|
3338
|
+
return Check(
|
|
3339
|
+
f"fix:{tool}",
|
|
3340
|
+
"warn",
|
|
3341
|
+
f"`--fix` could not start `{command}` for {tool}: {err}. Run it "
|
|
3342
|
+
f"yourself, then re-run `tan doctor`.",
|
|
3343
|
+
command,
|
|
3344
|
+
code="doctor.fix-spawn-failed",
|
|
3345
|
+
scope="host",
|
|
3346
|
+
)
|
|
3347
|
+
|
|
3348
|
+
|
|
3349
|
+
def fix_failed_check(tool: str, command: str, returncode: int) -> Check:
|
|
3350
|
+
"""`doctor.fix-failed` -- `--fix` ran `{tool}`'s install command and the
|
|
3351
|
+
child exited non-zero. `hostPrerequisites` above still reports `{tool}`
|
|
3352
|
+
missing in THIS report (same no-same-process-recheck honesty as
|
|
3353
|
+
`fix_installed_check`) -- this Check is the only place a customer learns
|
|
3354
|
+
the install itself failed, rather than merely "still missing"."""
|
|
3355
|
+
return Check(
|
|
3356
|
+
f"fix:{tool}",
|
|
3357
|
+
"warn",
|
|
3358
|
+
f"`--fix` ran `{command}` for {tool}; it exited {returncode}. Run it "
|
|
3359
|
+
f"yourself to see the full output, then re-run `tan doctor`.",
|
|
3360
|
+
command,
|
|
3361
|
+
code="doctor.fix-failed",
|
|
3362
|
+
scope="host",
|
|
3363
|
+
)
|
|
3364
|
+
|
|
3365
|
+
|
|
3366
|
+
def fix_timed_out_check(tool: str, command: str) -> Check:
|
|
3367
|
+
"""`doctor.fix-timed-out` -- `{tool}`'s install command did not finish
|
|
3368
|
+
inside `FIX_INSTALL_TIMEOUT_S` (300s) and was killed. Without this, a
|
|
3369
|
+
hang here reads as up to 20 minutes of silent terminal: text-mode output
|
|
3370
|
+
only prints after the WHOLE report completes."""
|
|
3371
|
+
return Check(
|
|
3372
|
+
f"fix:{tool}",
|
|
3373
|
+
"warn",
|
|
3374
|
+
f"`--fix` killed `{command}` for {tool} after {FIX_INSTALL_TIMEOUT_S}s "
|
|
3375
|
+
f"with no result. Run it yourself, then re-run `tan doctor`.",
|
|
3376
|
+
command,
|
|
3377
|
+
code="doctor.fix-timed-out",
|
|
3378
|
+
scope="host",
|
|
3379
|
+
)
|
|
3380
|
+
|
|
3381
|
+
|
|
3382
|
+
#: Per-installer remedy for `fix_installer_not_found_check`, keyed by the first
|
|
3383
|
+
#: word of the command actually resolved and spawned -- for a `sudo`-prefixed
|
|
3384
|
+
#: manifest line that is the word AFTER `sudo` (tan-cli#650 strips it for an
|
|
3385
|
+
#: already-root caller before this table is ever consulted; a non-root caller
|
|
3386
|
+
#: is refused by `fix_needs_sudo_check` long before this point, so `sudo`
|
|
3387
|
+
#: itself never appears here as a key). Two named entries: `metadata/
|
|
3388
|
+
#: bootstrap.json`'s `prerequisites.install` produces `brew` on macOS and
|
|
3389
|
+
#: `winget` on Windows (see `_fallback_install_commands` in `tan.core.
|
|
3390
|
+
#: bootstrap`, which is byte-pinned to the manifest). Anything else --
|
|
3391
|
+
#: including `apt-get`, on the rare root-container host missing it -- gets
|
|
3392
|
+
#: the generic remedy below: a manifest is free to name a package manager
|
|
3393
|
+
#: this table has never heard of, and "not found" with no advice is the
|
|
3394
|
+
#: exact silence tan-cli#360 is about.
|
|
3395
|
+
INSTALLER_REMEDIES = {
|
|
3396
|
+
"brew": (
|
|
3397
|
+
"Homebrew is not installed -- install it from https://brew.sh, then "
|
|
3398
|
+
"re-run `tan doctor --fix`."
|
|
3399
|
+
),
|
|
3400
|
+
"winget": (
|
|
3401
|
+
'winget comes from the App Installer package -- install "App '
|
|
3402
|
+
'Installer" from the Microsoft Store (Windows 10 1809 or newer), open '
|
|
3403
|
+
"a new shell so PATH picks it up, then re-run `tan doctor --fix`."
|
|
3404
|
+
),
|
|
3405
|
+
}
|
|
3406
|
+
|
|
3407
|
+
|
|
3408
|
+
def fix_installer_not_found_check(installer: str, tools: list[str]) -> Check:
|
|
3409
|
+
"""`doctor.fix-installer-not-found` -- tan-cli#360: `--fix` could not
|
|
3410
|
+
resolve the program the manifest's install command starts with, so it ran
|
|
3411
|
+
NO repair for the tools that command covers.
|
|
3412
|
+
|
|
3413
|
+
This used to be a bare `continue` in `run_fix` -- the one outcome that
|
|
3414
|
+
emitted no Check at all, in a function whose whole stated invariant is
|
|
3415
|
+
that every entry is either run or refused and every outcome is reported.
|
|
3416
|
+
It is also the single most likely outcome on the hosts `--fix` exists
|
|
3417
|
+
for: the manifest installs with `brew` on macOS and `winget` on Windows,
|
|
3418
|
+
so a fresh Mac without Homebrew, or a Windows image with no usable
|
|
3419
|
+
winget, reported tools missing, ACCEPTED `--fix`, and then printed
|
|
3420
|
+
exactly the same report back with no `fix:*` line anywhere. The
|
|
3421
|
+
least-equipped host got the least diagnostic behaviour, and could not
|
|
3422
|
+
tell "nothing needed fixing" from "tan never found the installer to try
|
|
3423
|
+
with".
|
|
3424
|
+
|
|
3425
|
+
ONE Check per installer, not per tool: a fresh Mac is missing all six
|
|
3426
|
+
manifest prerequisites at once and every one of them says `brew`, so
|
|
3427
|
+
per-tool would print the same "install Homebrew" paragraph six times over
|
|
3428
|
+
-- six restatements of one fact, in a report a customer is reading to
|
|
3429
|
+
find out what is actually wrong. `tools` therefore carries every tool
|
|
3430
|
+
this one absence blocked, and `run_fix` groups them.
|
|
3431
|
+
|
|
3432
|
+
Named `fix:{installer}` rather than `fix:{tool}` for the same reason --
|
|
3433
|
+
the subject of this verdict is the installer, and one name per absent
|
|
3434
|
+
installer is what keeps the grouping legible in the text report. No
|
|
3435
|
+
`fix` field, and no "see `hostPrerequisites` above" either (tan-cli#760
|
|
3436
|
+
review round 3, MINOR): the per-tool commands are not runnable until the
|
|
3437
|
+
installer exists, and this Check fires precisely when that installer is
|
|
3438
|
+
unconfirmed -- the exact condition under which `hostPrerequisites.
|
|
3439
|
+
missing[].command` is `null` too, so pointing at it would be false on
|
|
3440
|
+
the one host this Check describes.
|
|
3441
|
+
"""
|
|
3442
|
+
named = ", ".join(tools)
|
|
3443
|
+
many = len(tools) > 1
|
|
3444
|
+
remedy = INSTALLER_REMEDIES.get(installer) or (
|
|
3445
|
+
f"Install `{installer}`, then re-run `tan doctor --fix`."
|
|
3446
|
+
)
|
|
3447
|
+
return Check(
|
|
3448
|
+
f"fix:{installer}",
|
|
3449
|
+
"warn",
|
|
3450
|
+
f"`--fix` ran no repair for {named}: the manifest installs "
|
|
3451
|
+
f"{'them' if many else 'it'} with `{installer}`, which is not on "
|
|
3452
|
+
f"PATH. {remedy} Or install {'those tools' if many else named} with a "
|
|
3453
|
+
f"package manager this host already has.",
|
|
3454
|
+
code="doctor.fix-installer-not-found",
|
|
3455
|
+
scope="host",
|
|
3456
|
+
)
|
|
3457
|
+
|
|
3458
|
+
|
|
3459
|
+
def _running_as_root() -> bool:
|
|
3460
|
+
"""tan-cli#650: whether THIS process's effective UID is 0 -- POSIX only.
|
|
3461
|
+
|
|
3462
|
+
`os.geteuid` does not exist on Windows, where no manifest install
|
|
3463
|
+
command ever carries a `sudo` prefix in the first place (`winget` is
|
|
3464
|
+
user-scope) -- see `fix_needs_sudo_check`'s docstring -- so this is only
|
|
3465
|
+
ever consulted from the one platform whose manifest can raise the
|
|
3466
|
+
question at all, and `getattr` guards the call rather than an `if
|
|
3467
|
+
sys.platform` branch so this stays correct if that ever changes.
|
|
3468
|
+
|
|
3469
|
+
The caller this exists for: a root container (`docker run`, most CI base
|
|
3470
|
+
images, a fresh cloud VM before its first `useradd`) running `tan doctor
|
|
3471
|
+
--build --fix`. There is no elevation left to acquire there, so
|
|
3472
|
+
`run_fix` refusing every `sudo`-prefixed manifest command unconditionally
|
|
3473
|
+
-- which it did before this -- was not caution, it was a false negative:
|
|
3474
|
+
measured live via a real `geteuid() == 0` process (`unshare --user
|
|
3475
|
+
--map-root-user`, a genuine root user namespace, since this box has no
|
|
3476
|
+
passwordless real root), `run_fix` still emitted `doctor.fix-needs-sudo`
|
|
3477
|
+
and ran nothing.
|
|
3478
|
+
"""
|
|
3479
|
+
geteuid = getattr(os, "geteuid", None)
|
|
3480
|
+
return geteuid is not None and geteuid() == 0
|
|
3481
|
+
|
|
3482
|
+
|
|
3483
|
+
def run_fix(
|
|
3484
|
+
missing: list[dict[str, str | int | None]],
|
|
3485
|
+
on_check: Callable[[Check], None] | None = None,
|
|
3486
|
+
) -> list[Check]:
|
|
3487
|
+
"""`--fix`'s ADR 0021 executor (tan-cli#91): for each tool
|
|
3488
|
+
`hostPrerequisites` already reported missing, either run its manifest
|
|
3489
|
+
install command (no elevation needed -- Tier A) or refuse and name it
|
|
3490
|
+
(needs `sudo` -- Tier B), never both, never neither. `missing` is that
|
|
3491
|
+
check's OWN structured field -- but `Check.fix_missing`, NOT `Check.
|
|
3492
|
+
missing` (tan-cli#760 review round 3, MINOR: this used to say `Check.
|
|
3493
|
+
missing`, which stopped being true the moment tan-cli#760 split the two).
|
|
3494
|
+
`fix_missing` is the RAW twin: `missing` (`data.missingPrerequisites`,
|
|
3495
|
+
the envelope) nulls a command whose own installer this host cannot
|
|
3496
|
+
confirm, and feeding THAT to `run_fix` would silently disable the Tier B
|
|
3497
|
+
diagnosis below the moment a tool's command is unconfirmed -- the exact
|
|
3498
|
+
regression tan-cli#760's own review caught. Never a second,
|
|
3499
|
+
independently recomputed tool/command list either way, so this can only
|
|
3500
|
+
ever act on exactly what `prerequisites_check` already computed.
|
|
3501
|
+
|
|
3502
|
+
A tool with `command=None` (the manifest names no install command for
|
|
3503
|
+
it) is skipped outright: nothing to run, nothing to refuse, and the
|
|
3504
|
+
existing `hostPrerequisites` Fail already carries the honest "install it
|
|
3505
|
+
yourself" advice for that case.
|
|
3506
|
+
|
|
3507
|
+
Every outcome becomes a `Check` -- `fix_needs_sudo_check`/
|
|
3508
|
+
`fix_installed_check` on the two "acted, and it's fine" paths, (as of
|
|
3509
|
+
the tan-cli#91 follow-up below) `fix_spawn_failed_check`/`fix_failed_check`/
|
|
3510
|
+
`fix_timed_out_check` on the three "acted, and it's NOT fine" paths, and
|
|
3511
|
+
(tan-cli#360) `fix_installer_not_found_check` on the "could not act at
|
|
3512
|
+
all" one -- never a bare side effect. A customer who typed `--fix` and
|
|
3513
|
+
got the SAME report back used to have no way to tell "nothing needed
|
|
3514
|
+
fixing" from "tan tried and silently gave up": a spawn error, a non-zero
|
|
3515
|
+
exit, a `FIX_INSTALL_TIMEOUT_S` (300s) timeout, or an install command
|
|
3516
|
+
whose own program is not on PATH each used to `continue` with no trace at
|
|
3517
|
+
all. `hostPrerequisites`'s own Fail still names the tool and its command
|
|
3518
|
+
either way; these Checks add the ONE fact it structurally cannot carry --
|
|
3519
|
+
what `--fix` itself did about it.
|
|
3520
|
+
|
|
3521
|
+
`on_check` -- same shape, same job as `_collect`'s parameter of that
|
|
3522
|
+
name: fired the MOMENT one tool's own outcome is known, not after every
|
|
3523
|
+
tool has been tried. Before this, `--fix`'s checks streamed only once
|
|
3524
|
+
`run_fix` returned IN FULL -- the LARGEST blank-terminal window in the
|
|
3525
|
+
whole command, `FIX_INSTALL_TIMEOUT_S` (300s) times up to 4 tools,
|
|
3526
|
+
against `PROBE_TIMEOUT_S` (15s) for the probes `_collect` already
|
|
3527
|
+
streamed. `doctor()` passes it the SAME `_print_check` it gives
|
|
3528
|
+
`_collect`, so a `--fix` outcome prints through the identical guarded
|
|
3529
|
+
`_print_stream_lines` path (see its docstring) as every other check in
|
|
3530
|
+
this report.
|
|
3531
|
+
|
|
3532
|
+
Only ever called from `doctor()`'s `--fix` branch, itself gated on
|
|
3533
|
+
`can_prompt` (`tan.core.consent`) -- the one place in this module that
|
|
3534
|
+
mutates the host rather than merely observing it, so it is confined
|
|
3535
|
+
exactly there, never folded into `_collect` (pure probes, see the module
|
|
3536
|
+
docstring).
|
|
3537
|
+
"""
|
|
3538
|
+
results: list[Check] = []
|
|
3539
|
+
|
|
3540
|
+
def _add(check: Check) -> None:
|
|
3541
|
+
# Mirrors `_collect`'s own `_add` closure exactly -- append, then
|
|
3542
|
+
# stream, so a caller with no `on_check` (every direct unit-test
|
|
3543
|
+
# caller of this function) sees no behaviour change at all.
|
|
3544
|
+
results.append(check)
|
|
3545
|
+
if on_check is not None:
|
|
3546
|
+
on_check(check)
|
|
3547
|
+
|
|
3548
|
+
# installer -> every tool whose install command starts with it and that
|
|
3549
|
+
# therefore went unrepaired (tan-cli#360). Insertion-ordered, so the
|
|
3550
|
+
# grouped Checks appended below come out in `missing` order rather than
|
|
3551
|
+
# an arbitrary one.
|
|
3552
|
+
unresolved: dict[str, list[str]] = {}
|
|
3553
|
+
for entry in missing:
|
|
3554
|
+
tool = entry.get("tool")
|
|
3555
|
+
command = entry.get("command")
|
|
3556
|
+
if not tool or not command:
|
|
3557
|
+
continue
|
|
3558
|
+
# tan-cli#650: `effective_command` is what actually gets resolved and
|
|
3559
|
+
# spawned below; `command` (the manifest's own, untouched line) is
|
|
3560
|
+
# kept only for `fix_needs_sudo_check`'s refusal message, which must
|
|
3561
|
+
# name the exact line a human would paste into a real terminal.
|
|
3562
|
+
effective_command = command
|
|
3563
|
+
elevation_skipped = False
|
|
3564
|
+
if command.strip().startswith("sudo "):
|
|
3565
|
+
if not _running_as_root():
|
|
3566
|
+
_add(fix_needs_sudo_check(tool, command))
|
|
3567
|
+
continue
|
|
3568
|
+
# Already root: there is nothing left to elevate to, and this
|
|
3569
|
+
# process still never spawns the `sudo` PROGRAM (see
|
|
3570
|
+
# `fix_needs_sudo_check`'s docstring) -- it strips the literal
|
|
3571
|
+
# `sudo ` word and runs the manifest's OWN remaining command
|
|
3572
|
+
# directly, the same way every non-elevated entry already does.
|
|
3573
|
+
effective_command = command.strip()[len("sudo ") :].lstrip()
|
|
3574
|
+
elevation_skipped = True
|
|
3575
|
+
argv = shlex.split(effective_command)
|
|
3576
|
+
if not argv:
|
|
3577
|
+
# The one deliberately silent skip left (tan-cli#360 reviewed the
|
|
3578
|
+
# rest): an all-whitespace install command names no program to
|
|
3579
|
+
# report as absent, so there is nothing this could say beyond what
|
|
3580
|
+
# `hostPrerequisites`'s own Fail already says about the tool. A
|
|
3581
|
+
# manifest defect, not a host one.
|
|
3582
|
+
continue
|
|
3583
|
+
# `on_path`, never bare `subprocess.run([name, ...])`: the same
|
|
3584
|
+
# PATH-only, no-cwd-insertion resolver every other spawn in this
|
|
3585
|
+
# module uses (see `on_path`'s own docstring) -- a project-local
|
|
3586
|
+
# binary happening to share the tool's name must not be what `--fix`
|
|
3587
|
+
# runs with elevated-sounding trust.
|
|
3588
|
+
resolved_exe = on_path(argv[0])
|
|
3589
|
+
if resolved_exe is None:
|
|
3590
|
+
# tan-cli#360: collected, not silently dropped. Grouped by
|
|
3591
|
+
# installer because one absent `brew` blocks every macOS
|
|
3592
|
+
# prerequisite at once -- see `fix_installer_not_found_check`.
|
|
3593
|
+
unresolved.setdefault(argv[0], []).append(tool)
|
|
3594
|
+
continue
|
|
3595
|
+
argv[0] = resolved_exe
|
|
3596
|
+
try:
|
|
3597
|
+
result = subprocess.run(
|
|
3598
|
+
argv,
|
|
3599
|
+
capture_output=True,
|
|
3600
|
+
text=True,
|
|
3601
|
+
encoding="utf-8",
|
|
3602
|
+
errors="replace",
|
|
3603
|
+
stdin=subprocess.DEVNULL,
|
|
3604
|
+
timeout=FIX_INSTALL_TIMEOUT_S,
|
|
3605
|
+
env=spawn_env(),
|
|
3606
|
+
check=False,
|
|
3607
|
+
)
|
|
3608
|
+
except subprocess.TimeoutExpired:
|
|
3609
|
+
_add(fix_timed_out_check(tool, effective_command))
|
|
3610
|
+
continue
|
|
3611
|
+
except (OSError, ValueError, subprocess.SubprocessError) as err:
|
|
3612
|
+
_add(fix_spawn_failed_check(tool, effective_command, err))
|
|
3613
|
+
continue
|
|
3614
|
+
if result.returncode == 0:
|
|
3615
|
+
_add(
|
|
3616
|
+
fix_installed_check(
|
|
3617
|
+
tool, effective_command, elevation_skipped=elevation_skipped
|
|
3618
|
+
)
|
|
3619
|
+
)
|
|
3620
|
+
else:
|
|
3621
|
+
_add(fix_failed_check(tool, effective_command, result.returncode))
|
|
3622
|
+
for installer, tools in unresolved.items():
|
|
3623
|
+
_add(fix_installer_not_found_check(installer, tools))
|
|
3624
|
+
return results
|
|
3625
|
+
|
|
3626
|
+
|
|
3627
|
+
def fix_suppressed_issue(*, non_interactive: bool, ci: bool, json_mode: bool) -> Issue:
|
|
3628
|
+
"""`doctor.fix-suppressed` -- tan-cli#91 P1, measured against the oracle:
|
|
3629
|
+
`tan doctor --fix --format json` on an unhealthy host used to be a
|
|
3630
|
+
byte-for-byte silent no-op vs. plain `tan doctor` -- no issue, no note,
|
|
3631
|
+
exit code unchanged -- indistinguishable from a `--fix` that genuinely
|
|
3632
|
+
found nothing to do. The oracle's own equivalent refuses outright
|
|
3633
|
+
(`cli.parse-error`, exit 2); this port instead reports HONESTLY: `--fix`
|
|
3634
|
+
was requested, the `can_prompt` consent gate (`tan.core.consent`) refused
|
|
3635
|
+
it, and here is which of its conditions actually tripped -- not just that
|
|
3636
|
+
nothing happened.
|
|
3637
|
+
|
|
3638
|
+
Only ever called from `doctor()`, and only when `fix` is set and
|
|
3639
|
+
`can_prompt` returned `False` for these same three flags -- never the
|
|
3640
|
+
other way around, so this can only ever explain a REAL suppression.
|
|
3641
|
+
|
|
3642
|
+
The `isatty()` pair is read ONLY when `not json_mode`, mirroring
|
|
3643
|
+
`can_prompt`'s own short-circuit order (`... and not json_mode and
|
|
3644
|
+
sys.stdin.isatty() and sys.stderr.isatty()`) rather than a coincidence:
|
|
3645
|
+
under `--format json`, `tan.cli.main` tees `sys.stderr` through
|
|
3646
|
+
`_TeeStderr`, which has no `isatty()` at all -- reading it unconditionally
|
|
3647
|
+
here crashes this exact suppressed-fix report with
|
|
3648
|
+
`AttributeError: '_TeeStderr' object has no attribute 'isatty'` (measured
|
|
3649
|
+
against a real `tan doctor --fix --format json --ci` run). `json_mode`
|
|
3650
|
+
is already a complete, accurate reason on its own; there is nothing the
|
|
3651
|
+
tty state could add under it.
|
|
3652
|
+
|
|
3653
|
+
tan-cli#488 defect 6: `sys.stdin` is guarded with `is not None` before
|
|
3654
|
+
`.isatty()` -- `sys.stdin` itself, not just `sys.stderr`, can be `None`
|
|
3655
|
+
(a GUI-launched/`pythonw`-style process, or any host that runs tan with
|
|
3656
|
+
its standard handles detached), and a bare `sys.stdin.isatty()` there
|
|
3657
|
+
raises `AttributeError: 'NoneType' object has no attribute 'isatty'` for
|
|
3658
|
+
the same reason the `_TeeStderr` case above already gets its own guard.
|
|
3659
|
+
|
|
3660
|
+
tan-cli#488 round 3: this was, for one round, the ONLY place that guard
|
|
3661
|
+
existed -- `can_prompt` itself (`tan.core.consent`) still called the bare
|
|
3662
|
+
`sys.stdin.isatty()`. Since `doctor()` calls `can_prompt` BEFORE it ever
|
|
3663
|
+
calls this function (`fix_allowed = fix and can_prompt(...)`, evaluated
|
|
3664
|
+
ahead of the `fix_suppressed_issue(...)` call site below it), a `None`
|
|
3665
|
+
`sys.stdin` crashed there first, and this function's own guard never ran
|
|
3666
|
+
at all -- caught only by `doctor()`'s outer `except Exception`, which
|
|
3667
|
+
discarded the whole diagnosis. `can_prompt` now carries the identical
|
|
3668
|
+
guard (the fix belongs there, once, not duplicated per caller -- see its
|
|
3669
|
+
own docstring); the copy here stays only because this function explains
|
|
3670
|
+
each tripped condition individually, not merely whether the aggregate
|
|
3671
|
+
gate passed.
|
|
3672
|
+
|
|
3673
|
+
tan-cli#488 round 5: `sys.stderr`, the NEXT operand of this same `and`
|
|
3674
|
+
chain, was left unguarded through rounds 3 and 4 -- `sys.stderr` can be
|
|
3675
|
+
`None` independently of `sys.stdin` (each handle is detached on its own
|
|
3676
|
+
by whatever spawned the process), so a host with a live, non-tty stdin
|
|
3677
|
+
and a detached stderr still crashed here with the identical
|
|
3678
|
+
`AttributeError`, one operand later. `can_prompt` now guards both; this
|
|
3679
|
+
copy must match it exactly or the two diverge on the very host this
|
|
3680
|
+
function exists to explain.
|
|
3681
|
+
|
|
3682
|
+
tan-cli#488 round 6: `is not None` still was not the whole guard -- it
|
|
3683
|
+
stops a detached (`None`) handle from crashing `.isatty()`, but not a
|
|
3684
|
+
handle that EXISTS and simply has no `.isatty()` method, exactly the
|
|
3685
|
+
shape `sys.stderr` takes under `--format json` (`tan.cli.main`'s
|
|
3686
|
+
`_TeeStderr`, which implements `write`/`flush`/`getvalue` only). The
|
|
3687
|
+
`not json_mode` guard in front of this whole condition means this
|
|
3688
|
+
particular copy was never observed to reach that shape in practice --
|
|
3689
|
+
but `tan.commands.build_cmd._dispatch` hand-rolled the identical
|
|
3690
|
+
`is not None and .isatty()` pair with no `json_mode` operand ahead of
|
|
3691
|
+
it, and that one DID crash on a real `tan run --format json`
|
|
3692
|
+
(`AttributeError: '_TeeStderr' object has no attribute 'isatty'`,
|
|
3693
|
+
measured), one caller away from the same fate here. Both operands now
|
|
3694
|
+
route through `tan.env.stdin_is_tty`/`stderr_is_tty` -- the one shared
|
|
3695
|
+
probe (tan-cli#288) already used by `use_color`/`wrap_width` above and
|
|
3696
|
+
now by `can_prompt` too -- instead of a fourth hand-rolled copy of a
|
|
3697
|
+
guard three of the previous four rounds already got wrong once.
|
|
3698
|
+
"""
|
|
3699
|
+
reasons = []
|
|
3700
|
+
if json_mode:
|
|
3701
|
+
reasons.append("`--format json` (no terminal to prompt on)")
|
|
3702
|
+
if ci:
|
|
3703
|
+
reasons.append("`--ci`")
|
|
3704
|
+
if non_interactive:
|
|
3705
|
+
reasons.append("`--non-interactive`")
|
|
3706
|
+
if not json_mode and not (stdin_is_tty() and stderr_is_tty()):
|
|
3707
|
+
reasons.append("no interactive terminal (stdin/stderr not a tty -- piped, redirected, or CI)")
|
|
3708
|
+
return Issue(
|
|
3709
|
+
"doctor.fix-suppressed",
|
|
3710
|
+
"warning",
|
|
3711
|
+
"`--fix` was requested but not run: " + "; ".join(reasons) + ". Re-run "
|
|
3712
|
+
"`tan doctor --fix` from a real, interactive terminal, without "
|
|
3713
|
+
"--ci/--non-interactive/--format json, to allow it.",
|
|
3714
|
+
)
|
|
3715
|
+
|
|
3716
|
+
|
|
3717
|
+
@dataclass(frozen=True)
|
|
3718
|
+
class _PrerequisitesEnvironment:
|
|
3719
|
+
"""Everything `bootstrapManifest`/`hostPrerequisites` (and `_collect`'s
|
|
3720
|
+
OWN `hostPython`/`pythonFloor`/`west` checks, which read the SAME
|
|
3721
|
+
manifest load and Python probe) need, computed exactly once regardless of
|
|
3722
|
+
which caller asked -- see `_resolve_prerequisites_environment` (tan-cli#441).
|
|
3723
|
+
"""
|
|
3724
|
+
|
|
3725
|
+
bootstrap_manifest_check: Check | None
|
|
3726
|
+
prerequisites_check: Check
|
|
3727
|
+
facts: dict
|
|
3728
|
+
manifest_floor: tuple[int, int]
|
|
3729
|
+
effective_floor: tuple[int, int]
|
|
3730
|
+
effective_source: str
|
|
3731
|
+
python_found: tuple[str, tuple[int, int], str] | None
|
|
3732
|
+
manifest_is_real: bool
|
|
3733
|
+
#: Every interpreter the ONE probe saw (tan-cli#1317) -- `hostPython`'s
|
|
3734
|
+
#: build-readiness verdict reads this instead of probing a second time.
|
|
3735
|
+
host_pythons: tuple = ()
|
|
3736
|
+
|
|
3737
|
+
|
|
3738
|
+
def _resolve_prerequisites_environment(
|
|
3739
|
+
sdk_root: str | None, workspace_path: Path | None
|
|
3740
|
+
) -> _PrerequisitesEnvironment:
|
|
3741
|
+
"""The manifest load through `hostPrerequisites` -- moved verbatim out of
|
|
3742
|
+
`_collect` (tan-cli#441) so `host_environment_checks` (support-bundle's
|
|
3743
|
+
seam onto exactly these two checks; see its own docstring) and `_collect`
|
|
3744
|
+
(`tan doctor`'s full checklist, which ALSO needs `hostPython`/
|
|
3745
|
+
`pythonFloor`/`west`'s `facts.get("_pipSpec")` off the SAME manifest load
|
|
3746
|
+
and the SAME Python probe) build `bootstrapManifest`/`hostPrerequisites`
|
|
3747
|
+
from ONE copy of this logic and pay for the manifest read + the
|
|
3748
|
+
`_probe_host_python` subprocess spawn exactly once per caller, instead of
|
|
3749
|
+
a second copy drifting the moment either check changes.
|
|
3750
|
+
|
|
3751
|
+
`workspace_path` is a parameter, not resolved here, so a caller that
|
|
3752
|
+
already has one (`_collect`, from its own earlier `west_workspace_dir`
|
|
3753
|
+
call) never pays for a second walk -- `host_environment_checks` below
|
|
3754
|
+
resolves its own before calling this.
|
|
3755
|
+
"""
|
|
3756
|
+
loaded = _load_manifest(sdk_root)
|
|
3757
|
+
facts, source = loaded.facts, loaded.source
|
|
3758
|
+
bootstrap_manifest_check: Check | None = None
|
|
3759
|
+
if loaded.error is not None:
|
|
3760
|
+
bootstrap_manifest_check = Check(
|
|
3761
|
+
"bootstrapManifest",
|
|
3762
|
+
"warn",
|
|
3763
|
+
f"metadata/bootstrap.json rejected: {loaded.error}. Falling back to "
|
|
3764
|
+
f"tan's built-in prerequisite list, which may not match this SDK.",
|
|
3765
|
+
"Update `tan` or pin an SDK whose metadata/bootstrap.json this "
|
|
3766
|
+
"version understands; `tan bootstrap` will refuse outright until then.",
|
|
3767
|
+
scope="project",
|
|
3768
|
+
)
|
|
3769
|
+
elif loaded.provenance_error is not None:
|
|
3770
|
+
# tan-cli#1066 review. SAME check name and SAME issue code as the
|
|
3771
|
+
# rejected-manifest arm above -- a consumer keying on
|
|
3772
|
+
# `doctor.bootstrap-manifest` sees both -- but its own wording, because
|
|
3773
|
+
# this manifest was NOT rejected: the tool list, the floor and the
|
|
3774
|
+
# install commands were all read normally and `is_real` is true, so
|
|
3775
|
+
# borrowing the "falling back to tan's built-in prerequisite list"
|
|
3776
|
+
# sentence would send the reader hunting a staleness that does not
|
|
3777
|
+
# exist. `warn`, never `fail`: the exit code and every reported entry
|
|
3778
|
+
# are byte-identical to a run with no provenance at all (the four keys
|
|
3779
|
+
# are still there, still `null`) -- what changes is that the failure is
|
|
3780
|
+
# now SAYABLE. `elif`: a manifest that was rejected outright never had
|
|
3781
|
+
# its provenance parsed in the first place, and the bigger fact wins.
|
|
3782
|
+
bootstrap_manifest_check = Check(
|
|
3783
|
+
"bootstrapManifest",
|
|
3784
|
+
"warn",
|
|
3785
|
+
f"metadata/bootstrap.json was read, but its artifactProvenance "
|
|
3786
|
+
f"block is not readable: {loaded.provenance_error}. Any affected "
|
|
3787
|
+
f"missingPrerequisites[] entries report tier/licence/sourceUrl/"
|
|
3788
|
+
f"sizeBytes as null; the prerequisite list itself is unaffected.",
|
|
3789
|
+
"Report this against alp-sdk -- its metadata/bootstrap.json "
|
|
3790
|
+
"generator produced the block, and `tan` needs no change. Nothing "
|
|
3791
|
+
"else in this report is affected.",
|
|
3792
|
+
scope="project",
|
|
3793
|
+
)
|
|
3794
|
+
|
|
3795
|
+
manifest_floor = _manifest_floor_from_facts(facts)
|
|
3796
|
+
# tan-cli#301 (second half): read the SAME resolved workspace `zephyrWorkspace`
|
|
3797
|
+
# reports above (`workspace_path`, from the shared `west_workspace_dir`) --
|
|
3798
|
+
# NOT a second, independent `$ZEPHYR_BASE` read. A stale exported
|
|
3799
|
+
# `$ZEPHYR_BASE` is common (Zephyr's own docs, and this command's own `tan
|
|
3800
|
+
# bootstrap` next-steps block, both tell a customer to export it), and
|
|
3801
|
+
# reading it here regardless of the resolved workspace is how one report
|
|
3802
|
+
# ended up citing two different Zephyrs. `$ZEPHYR_BASE` is consulted only as
|
|
3803
|
+
# `zephyr_python_floor`'s fallback, when no workspace resolved at all --
|
|
3804
|
+
# mirroring #290's fix for `zephyrWorkspace` itself.
|
|
3805
|
+
zephyr_source_base = (
|
|
3806
|
+
str(workspace_path / "zephyr")
|
|
3807
|
+
if workspace_path is not None
|
|
3808
|
+
else os.environ.get("ZEPHYR_BASE")
|
|
3809
|
+
)
|
|
3810
|
+
# tan-cli#606: when no workspace resolves, prefer alp-sdk's OWN declared
|
|
3811
|
+
# `zephyr.pythonMinVersion` over `ZEPHYR_PYTHON_FLOOR` -- same `facts`
|
|
3812
|
+
# dict `manifest_floor` above already reads, so this is not a second
|
|
3813
|
+
# manifest parse.
|
|
3814
|
+
zephyr_manifest_floor = _zephyr_manifest_floor_from_facts(facts)
|
|
3815
|
+
# tan tan-cli#1317: the ONE composition (`tan.core.python_floor`), shared
|
|
3816
|
+
# with `tan build`'s interpreter pick.
|
|
3817
|
+
effective_floor, effective_source = effective_python_floor(
|
|
3818
|
+
manifest_floor, zephyr_source_base, zephyr_manifest_floor
|
|
3819
|
+
)
|
|
3820
|
+
|
|
3821
|
+
host_pythons = probe_all_host_pythons()
|
|
3822
|
+
python_found = _probe_host_python(effective_floor, host_pythons)
|
|
3823
|
+
|
|
3824
|
+
# tan-cli#488 defect 2: keyed on the HOST, not a `windows`/`posix` bool.
|
|
3825
|
+
# `facts.get(...)` used to read straight off `"windows" if os.name == "nt"
|
|
3826
|
+
# else "posix"`, which has no `macos` arm at all -- so a stock Mac (no
|
|
3827
|
+
# `wget`, no standalone `xz`, both of which joined `prerequisites.posix`
|
|
3828
|
+
# at alp-sdk v0.14.0) got handed the POSIX list wholesale and reported
|
|
3829
|
+
# `hostPrerequisites: fail` on a host `tan bootstrap` accepts. Mirrors
|
|
3830
|
+
# `tan.core.bootstrap.BootstrapFacts.prerequisites`'s own host-keyed read
|
|
3831
|
+
# exactly, including its fallback: an EMPTY/absent `prerequisites.macos`
|
|
3832
|
+
# means the manifest declared none (every SDK before v0.14.0), and macOS
|
|
3833
|
+
# then reads `posix` exactly as it always did -- not a guess, the actual
|
|
3834
|
+
# pre-v0.14.0 behaviour.
|
|
3835
|
+
is_macos = sys.platform == "darwin"
|
|
3836
|
+
required = facts.get("windows" if os.name == "nt" else ("macos" if is_macos else "posix"))
|
|
3837
|
+
if is_macos and not (isinstance(required, list) and required):
|
|
3838
|
+
required = facts.get("posix")
|
|
3839
|
+
if not isinstance(required, list):
|
|
3840
|
+
required = []
|
|
3841
|
+
required = [t for t in required if isinstance(t, str)]
|
|
3842
|
+
install = facts.get("install")
|
|
3843
|
+
platform_key = "windows" if os.name == "nt" else ("macos" if sys.platform == "darwin" else "linux")
|
|
3844
|
+
if platform_key == "linux":
|
|
3845
|
+
# `install["linux"]` is package-manager-keyed, not tool-keyed like
|
|
3846
|
+
# `macos`/`windows` (alp-sdk#1464 / tan-cli#760's second half) --
|
|
3847
|
+
# `facts` is the RAW parsed manifest dict (`_load_manifest`), a
|
|
3848
|
+
# separate reader from `tan.core.bootstrap.parse_bootstrap_manifest`,
|
|
3849
|
+
# so it needs the SAME normalize/select reconciliation that reader
|
|
3850
|
+
# applies, not a flat `.get(platform_key)` (which would silently
|
|
3851
|
+
# return `{}` for every Linux tool, apt included).
|
|
3852
|
+
linux_pm = detect_linux_pm(lambda binary: on_path(binary) is not None)
|
|
3853
|
+
per_tool = select_linux_install(
|
|
3854
|
+
normalize_linux_install(install.get("linux") if isinstance(install, dict) else None),
|
|
3855
|
+
linux_pm,
|
|
3856
|
+
)
|
|
3857
|
+
else:
|
|
3858
|
+
per_tool = install.get(platform_key) if isinstance(install, dict) else None
|
|
3859
|
+
if not isinstance(per_tool, dict):
|
|
3860
|
+
per_tool = {}
|
|
3861
|
+
# RAW -- `prerequisites_check` below does its OWN tan-cli#760 PATH
|
|
3862
|
+
# confirmation (`available=`) to build the customer-facing `missing`
|
|
3863
|
+
# field; this dict stays unguarded because it is also the source of
|
|
3864
|
+
# `fix_missing`, `--fix`'s (`run_fix`'s) own input -- see
|
|
3865
|
+
# `prerequisites_check`'s docstring and tan-cli#760 review MAJOR 1 for
|
|
3866
|
+
# why those two must NOT be the same value.
|
|
3867
|
+
resolved_install = {k: v for k, v in per_tool.items() if isinstance(v, str)}
|
|
3868
|
+
missing_tools = [tool for tool in required if on_path(tool) is None]
|
|
3869
|
+
# tan-cli#294 finding 3: reintroduces tan-cli#161. Only reachable once the
|
|
3870
|
+
# tool list itself is clean AND a Python actually ran -- mirrors
|
|
3871
|
+
# `check_prerequisites`' own order (`crates/tan-cli/src/commands/
|
|
3872
|
+
# bootstrap/steps.rs:296-298`): presence first, `ensurepip` only after.
|
|
3873
|
+
venv_refusal = None
|
|
3874
|
+
if (
|
|
3875
|
+
sys.platform.startswith("linux")
|
|
3876
|
+
and not missing_tools
|
|
3877
|
+
and python_found is not None
|
|
3878
|
+
and not _posix_venv_capable(python_found[0].split(), executable=python_found[2])
|
|
3879
|
+
):
|
|
3880
|
+
venv_refusal = posix_venv_unusable()
|
|
3881
|
+
built_prerequisites_check = prerequisites_check(
|
|
3882
|
+
required,
|
|
3883
|
+
missing_tools,
|
|
3884
|
+
resolved_install,
|
|
3885
|
+
source,
|
|
3886
|
+
venv_refusal,
|
|
3887
|
+
# tan-cli#760: confirmed -- not merely present, not GUESSED --
|
|
3888
|
+
# against THIS host's real PATH walk (`on_path`, already used
|
|
3889
|
+
# above for the tool-presence probe itself).
|
|
3890
|
+
available=lambda binary: on_path(binary) is not None,
|
|
3891
|
+
# tan-cli#1066. `facts` is the manifest `_load_manifest` already
|
|
3892
|
+
# parsed and already resolved the SDK root for -- the join happens
|
|
3893
|
+
# in the ONE process that holds both halves, which is the whole
|
|
3894
|
+
# reason this lives in tan and not in the consumer.
|
|
3895
|
+
provenance=facts.get("_artifactProvenance"),
|
|
3896
|
+
)
|
|
3897
|
+
|
|
3898
|
+
return _PrerequisitesEnvironment(
|
|
3899
|
+
bootstrap_manifest_check=bootstrap_manifest_check,
|
|
3900
|
+
prerequisites_check=built_prerequisites_check,
|
|
3901
|
+
facts=facts,
|
|
3902
|
+
manifest_floor=manifest_floor,
|
|
3903
|
+
effective_floor=effective_floor,
|
|
3904
|
+
effective_source=effective_source,
|
|
3905
|
+
python_found=python_found,
|
|
3906
|
+
manifest_is_real=loaded.is_real,
|
|
3907
|
+
host_pythons=tuple(host_pythons),
|
|
3908
|
+
)
|
|
3909
|
+
|
|
3910
|
+
|
|
3911
|
+
def host_environment_checks(sdk_root: str | None, workspace_root: str = ".") -> list[Check]:
|
|
3912
|
+
"""The public/internal seam `support-bundle` harvests instead of running
|
|
3913
|
+
`_collect`'s whole build/flash-readiness checklist (tan-cli#441).
|
|
3914
|
+
|
|
3915
|
+
Exactly the five HOST-only checks `_HOST_CHECK_ORDER`
|
|
3916
|
+
(`support_bundle_cmd.py`) names -- `zephyrSdkAvailableForHost`,
|
|
3917
|
+
`longPaths` (Windows only), `homePath`, `bootstrapManifest` (only on a
|
|
3918
|
+
rejected manifest), `hostPrerequisites` -- none of which need a resolved
|
|
3919
|
+
board.yaml, a spawned `west`/JLink/SETOOLS probe, or git provenance. Every
|
|
3920
|
+
one of them is built by the SAME functions `_collect` calls
|
|
3921
|
+
(`zephyr_sdk_host_check`/`long_paths_check`/`home_path_check` directly,
|
|
3922
|
+
`bootstrapManifest`/`hostPrerequisites` via the shared
|
|
3923
|
+
`_resolve_prerequisites_environment`) -- this is a second CALLER, not a
|
|
3924
|
+
second COPY, so a change to any of the five reaches both `tan doctor` and
|
|
3925
|
+
`tan support-bundle` from one place.
|
|
3926
|
+
|
|
3927
|
+
Returned unordered by name (construction order here, not
|
|
3928
|
+
`_HOST_CHECK_ORDER`) -- `support_bundle_cmd._host_checks_from_doctor`
|
|
3929
|
+
already re-indexes by `.name` before use, exactly as it did when it
|
|
3930
|
+
harvested from `_collect`'s much longer list.
|
|
3931
|
+
"""
|
|
3932
|
+
workspace_path = west_workspace_dir(
|
|
3933
|
+
workspace_root, Path(sdk_root) if sdk_root is not None else None
|
|
3934
|
+
)
|
|
3935
|
+
host_os, host_arch = _host_os_arch_tags()
|
|
3936
|
+
checks = [zephyr_sdk_host_check(host_os, host_arch)]
|
|
3937
|
+
if os.name == "nt":
|
|
3938
|
+
checks.append(
|
|
3939
|
+
long_paths_check(_long_paths_enabled(), _git_core_longpaths(_resolve_git_executable()))
|
|
3940
|
+
)
|
|
3941
|
+
checks.append(home_path_check(os.environ.get("USERPROFILE" if os.name == "nt" else "HOME")))
|
|
3942
|
+
|
|
3943
|
+
prereq_env = _resolve_prerequisites_environment(sdk_root, workspace_path)
|
|
3944
|
+
if prereq_env.bootstrap_manifest_check is not None:
|
|
3945
|
+
checks.append(prereq_env.bootstrap_manifest_check)
|
|
3946
|
+
checks.append(prereq_env.prerequisites_check)
|
|
3947
|
+
return checks
|
|
3948
|
+
|
|
3949
|
+
|
|
3950
|
+
def _collect(
|
|
3951
|
+
sdk_root: str | None,
|
|
3952
|
+
build: bool = False,
|
|
3953
|
+
board_yaml: str | None = None,
|
|
3954
|
+
project_scope: str | None = None,
|
|
3955
|
+
workspace_root: str = ".",
|
|
3956
|
+
sdk_tier: str | None = None,
|
|
3957
|
+
broken_global_default: str | None = None,
|
|
3958
|
+
on_check: Callable[[Check], None] | None = None,
|
|
3959
|
+
) -> list[Check]:
|
|
3960
|
+
"""Every probe, in report order. `probe`/`on_path`/`_read_text` are the
|
|
3961
|
+
three ways THIS function's own body touches the outside world, and none
|
|
3962
|
+
of them can raise (see the module docstring).
|
|
3963
|
+
|
|
3964
|
+
`on_check`, when given, is a FOURTH path out -- `_collect` does not
|
|
3965
|
+
control what it does, only that it fires, so this function's "nothing
|
|
3966
|
+
here may raise" promise is only as strong as whatever callback the
|
|
3967
|
+
caller supplies. `doctor()`'s real callback (`_print_check`) prints
|
|
3968
|
+
through `_print_stream_lines`, which guards exactly that failure and
|
|
3969
|
+
explains why (see its docstring for the mechanism -- not restated here)
|
|
3970
|
+
rather than letting it unwind back through here and discard every check
|
|
3971
|
+
already collected. A direct test caller that passes a callback of its
|
|
3972
|
+
own is responsible for the same guarantee.
|
|
3973
|
+
|
|
3974
|
+
`on_check` (tan-cli streaming, change 3) fires the MOMENT each check is
|
|
3975
|
+
added, in the exact same order -- `doctor()`'s text branch passes a
|
|
3976
|
+
callback that prints that one check's block immediately, so a slow probe
|
|
3977
|
+
later in this function never leaves an already-answered check unprinted
|
|
3978
|
+
on screen. `None` (every direct test caller of this function, and JSON
|
|
3979
|
+
mode) skips the callback entirely and this returns exactly the batch
|
|
3980
|
+
`list[Check]` it always has.
|
|
3981
|
+
|
|
3982
|
+
`build` (`--build`) is accepted and forwarded from `doctor()` but no
|
|
3983
|
+
longer changes anything here (tan-cli#290): `zephyrWorkspace`, the last
|
|
3984
|
+
check it used to gate, now runs unconditionally alongside `workspace`/
|
|
3985
|
+
`zephyrVersion` -- see `zephyr_workspace_check`'s docstring for why. Kept
|
|
3986
|
+
as a parameter rather than dropped so every existing direct caller (this
|
|
3987
|
+
file's own test suite, and the CLI's own forwarding call) keeps working
|
|
3988
|
+
unchanged; `alp-sdk-vscode`'s `["doctor", "--build"]` call sites keep
|
|
3989
|
+
working too, they just no longer see a different check list.
|
|
3990
|
+
|
|
3991
|
+
`board_yaml`/`project_scope`/`workspace_root` feed the tan-cli#294/#290
|
|
3992
|
+
build-environment preflight (`sdk`/`boardYaml`/`workspace`/
|
|
3993
|
+
`westResolved`/`venvProvenance`/`zephyrVersion`/`zephyrWorkspace`) -- all
|
|
3994
|
+
default so every existing direct caller (this file's own test suite)
|
|
3995
|
+
keeps working unchanged; those checks then simply report against "no
|
|
3996
|
+
board.yaml"/"no workspace resolved from `.`", which is an honest verdict,
|
|
3997
|
+
not a skipped one. `venvProvenance` (tan-cli#292) is the exception that
|
|
3998
|
+
proves the rule: it emits NO check at all (not even against "no board.yaml")
|
|
3999
|
+
when the resolved venv carries no provenance record, which is the common
|
|
4000
|
+
case for a workspace alp-sdk's own `bootstrap.sh` set up.
|
|
4001
|
+
|
|
4002
|
+
`boardYaml`'s severity needs one more fact: whether a project was
|
|
4003
|
+
actually SELECTED (`--project`/`--board-yaml` given), not merely whether
|
|
4004
|
+
the guessed path exists (tan-cli#294 review). `board_yaml` doubles as
|
|
4005
|
+
that signal here: the only way it is non-`None` while its file does NOT
|
|
4006
|
+
exist is an explicitly-given `--board-yaml` (`doctor()`'s own
|
|
4007
|
+
auto-discovery only ever sets it to a path that already `is_file()`), so
|
|
4008
|
+
`board_yaml is not None` is a safe proxy for "explicitly given" exactly
|
|
4009
|
+
where it matters -- the branch where `present` is False.
|
|
4010
|
+
|
|
4011
|
+
`sdk_tier` -- the `SdkSourceTier` `resolve_sdk_root_ladder` answered
|
|
4012
|
+
`sdk_root` with, threaded through so `sdk_check` (tan-cli#301) can name
|
|
4013
|
+
it. Optional/defaulted for the same reason every other parameter here is:
|
|
4014
|
+
every existing direct caller keeps working, reporting `sdk` with no tier
|
|
4015
|
+
parenthetical rather than a guessed one.
|
|
4016
|
+
|
|
4017
|
+
`broken_global_default` (tan-cli#344) -- the raw `sdkPath` a dangling
|
|
4018
|
+
`~/.alp/sdk-default` pointer names, computed once by the caller
|
|
4019
|
+
(`_broken_global_default`) and threaded straight to `sdk_check`. Optional/
|
|
4020
|
+
defaulted like `sdk_tier`; only changes the `sdk` check's remedy text, and
|
|
4021
|
+
only in the branch `sdk_root is None` already reaches.
|
|
4022
|
+
"""
|
|
4023
|
+
checks: list[Check] = []
|
|
4024
|
+
|
|
4025
|
+
def _add(check: Check) -> None:
|
|
4026
|
+
checks.append(check)
|
|
4027
|
+
if on_check is not None:
|
|
4028
|
+
on_check(check)
|
|
4029
|
+
|
|
4030
|
+
# tan-cli#294 finding 2: build-environment preflight -- LEADS the report,
|
|
4031
|
+
# mirroring Rust's `prepend_doctor_checks(..., probe_build_preflight(...))`:
|
|
4032
|
+
# "can a build even start" outranks every host-tool probe below.
|
|
4033
|
+
#
|
|
4034
|
+
# tan-cli#301: a checkout discoverable from cwd that a HIGHER tier
|
|
4035
|
+
# outranked is surfaced too, but ONLY the discovery `sdk_check` itself
|
|
4036
|
+
# would have used were nothing above it configured (`discover_sdk_root`,
|
|
4037
|
+
# the WIDE walk `resolve_sdk_root_ladder`'s own tail already falls back
|
|
4038
|
+
# to) -- reusing that exact helper instead of a second, hand-rolled scan
|
|
4039
|
+
# is what keeps this a report-only addition: it can only ever name a
|
|
4040
|
+
# candidate the ladder itself already knows how to reach, never invent
|
|
4041
|
+
# one of its own. Skipped when the winning tier already IS discovery (or
|
|
4042
|
+
# nothing): there is nothing "unselected" left to name.
|
|
4043
|
+
unselected_candidate: str | None = None
|
|
4044
|
+
if sdk_root is not None and sdk_tier not in (None, "discovery", "none"):
|
|
4045
|
+
candidate = discover_sdk_root(Path(workspace_root))
|
|
4046
|
+
# `normcase` BOTH sides. `_abs_posix` is `abspath` + slash-swap and
|
|
4047
|
+
# deliberately does not resolve, so on Windows the SAME directory
|
|
4048
|
+
# spelled with different case -- a `~/.alp/sdk-default` written from a
|
|
4049
|
+
# differently-cased `tan sdk switch`, or a differing drive-letter case
|
|
4050
|
+
# -- compared unequal and the report told the user to select the SDK
|
|
4051
|
+
# that was already selected:
|
|
4052
|
+
# alp-sdk at ...\ws\ALP-SDK (globalDefault; a checkout at
|
|
4053
|
+
# ...\ws\alp-sdk was not selected -- pass --sdk-root ... to use it)
|
|
4054
|
+
# A report that lies is the defect class #301 exists to close, so it
|
|
4055
|
+
# must not be reintroduced by the fix for it. No-op on POSIX.
|
|
4056
|
+
if candidate is not None and os.path.normcase(
|
|
4057
|
+
_abs_posix(str(candidate))
|
|
4058
|
+
) != os.path.normcase(_abs_posix(sdk_root)):
|
|
4059
|
+
unselected_candidate = str(candidate)
|
|
4060
|
+
# tan-cli#407: the #301 block above deliberately says nothing when the
|
|
4061
|
+
# winning tier already IS `discovery` -- and that is precisely the case
|
|
4062
|
+
# where the second checkout is not merely "unselected" but ACTIVELY in
|
|
4063
|
+
# use, by the four commands that take the wide ladder. Same report-only
|
|
4064
|
+
# discipline as #301: the candidate can only ever be one
|
|
4065
|
+
# `resolve_sdk_root_wide` itself would reach, never a scan of its own.
|
|
4066
|
+
divergent_candidate: str | None = None
|
|
4067
|
+
if sdk_root is not None and sdk_tier == "discovery":
|
|
4068
|
+
wide = resolve_sdk_root_wide(None, Path(workspace_root)).path
|
|
4069
|
+
# `normcase` both sides, for the reason spelled out in the #301 block
|
|
4070
|
+
# above: `_abs_posix` does not resolve, so on Windows one directory
|
|
4071
|
+
# under two spellings would be reported as two checkouts.
|
|
4072
|
+
if wide is not None and os.path.normcase(_abs_posix(str(wide))) != os.path.normcase(
|
|
4073
|
+
_abs_posix(sdk_root)
|
|
4074
|
+
):
|
|
4075
|
+
divergent_candidate = str(wide)
|
|
4076
|
+
# tan-cli#727. Guarded at THIS call site, not inside
|
|
4077
|
+
# `resolve_sdk_root_ladder` -- the same placement `build_cmd`,
|
|
4078
|
+
# `run_cmd`, `validate_cmd`, `clean_cmd.sdk_root_resolves` and
|
|
4079
|
+
# `flash_cmd._resolve_sdk` each chose, because every other caller depends
|
|
4080
|
+
# on the ladder handing the flag back unvalidated.
|
|
4081
|
+
dangling_flag_root = (
|
|
4082
|
+
sdk_root if sdk_tier == "sdkRootFlag" and not is_sdk_root(sdk_root) else None
|
|
4083
|
+
)
|
|
4084
|
+
_add(
|
|
4085
|
+
sdk_check(
|
|
4086
|
+
sdk_root,
|
|
4087
|
+
project_scope,
|
|
4088
|
+
sdk_tier,
|
|
4089
|
+
unselected_candidate,
|
|
4090
|
+
broken_global_default,
|
|
4091
|
+
divergent_candidate,
|
|
4092
|
+
dangling_flag_root,
|
|
4093
|
+
)
|
|
4094
|
+
)
|
|
4095
|
+
project_selected = bool(project_scope and project_scope.strip()) or board_yaml is not None
|
|
4096
|
+
_add(
|
|
4097
|
+
board_yaml_preflight_check(
|
|
4098
|
+
board_yaml is not None and Path(board_yaml).is_file(), project_selected
|
|
4099
|
+
)
|
|
4100
|
+
)
|
|
4101
|
+
# Immediately after `boardYaml`, and reading the SAME two facts `sdk` and
|
|
4102
|
+
# `boardYaml` just reported on -- the selection is a fact ABOUT that file,
|
|
4103
|
+
# resolved against THAT checkout, so a report where the two disagree would
|
|
4104
|
+
# be incoherent. Emits nothing at all for a project that selects no
|
|
4105
|
+
# curated libraries (see `libraries_check`).
|
|
4106
|
+
libraries = libraries_check(
|
|
4107
|
+
inspect_selection(
|
|
4108
|
+
Path(board_yaml) if board_yaml is not None else None,
|
|
4109
|
+
Path(sdk_root) if sdk_root is not None else None,
|
|
4110
|
+
)
|
|
4111
|
+
)
|
|
4112
|
+
if libraries is not None:
|
|
4113
|
+
_add(libraries)
|
|
4114
|
+
workspace_path = west_workspace_dir(
|
|
4115
|
+
workspace_root, Path(sdk_root) if sdk_root is not None else None
|
|
4116
|
+
)
|
|
4117
|
+
_add(
|
|
4118
|
+
workspace_preflight_check(
|
|
4119
|
+
str(workspace_path) if workspace_path is not None else None,
|
|
4120
|
+
start=workspace_root,
|
|
4121
|
+
sdk_root=sdk_root,
|
|
4122
|
+
zephyr_base=os.environ.get("ZEPHYR_BASE"),
|
|
4123
|
+
)
|
|
4124
|
+
)
|
|
4125
|
+
|
|
4126
|
+
# tan-cli#290: `westResolved`, right after `workspace` -- the same order
|
|
4127
|
+
# Rust's `build_preflight_checks` uses (`sdk`, `boardYaml`, `workspace`,
|
|
4128
|
+
# `westResolved`, `zephyrVersion`). The resolved binary is the SAME one
|
|
4129
|
+
# `tan build` would spawn (`tan.core.venv.west_program`): an absolute
|
|
4130
|
+
# venv path is trusted directly (`find_workspace_venv` already confirmed
|
|
4131
|
+
# it exists), a bare `"west"` fallback is re-checked against PATH, never
|
|
4132
|
+
# the reverse -- so a `westResolved` version can never be attributed to a
|
|
4133
|
+
# different binary than the one that answered it (tan-cli#123's exact
|
|
4134
|
+
# bug, reintroduced by the port and closed here).
|
|
4135
|
+
resolved_west = west_program(workspace_root, sdk_root)
|
|
4136
|
+
west_resolved_exe = (
|
|
4137
|
+
resolved_west if os.path.isabs(resolved_west) else on_path(resolved_west)
|
|
4138
|
+
)
|
|
4139
|
+
# tan-cli#488 defect 1: `probe_status`, not `probe` -- `west_resolved_ran`
|
|
4140
|
+
# tells `west_resolved_check` whether `--version` actually ran to a
|
|
4141
|
+
# successful exit, separately from whether its output parsed. `probe()`
|
|
4142
|
+
# alone collapses "could not be spawned" (a `west` launcher whose file
|
|
4143
|
+
# survives a relocated/renamed workspace but whose interpreter does not)
|
|
4144
|
+
# and "spawned, exited zero, printed something unparseable" into the same
|
|
4145
|
+
# `None` -- `pass` is only correct for the second.
|
|
4146
|
+
west_resolved_ran, west_resolved_stdout = (
|
|
4147
|
+
probe_status([west_resolved_exe, "--version"])
|
|
4148
|
+
if west_resolved_exe is not None
|
|
4149
|
+
else (False, None)
|
|
4150
|
+
)
|
|
4151
|
+
west_resolved_version = _parse_two(west_resolved_stdout or "")
|
|
4152
|
+
_add(west_resolved_check(west_resolved_exe, west_resolved_version, west_resolved_ran))
|
|
4153
|
+
|
|
4154
|
+
# tan-cli#292: `venvProvenance`, right beside `westResolved` -- it is a
|
|
4155
|
+
# verdict on the SAME resolved venv (`find_workspace_venv`, the search
|
|
4156
|
+
# `west_program` itself resolves `west` through), just reading its
|
|
4157
|
+
# tan-written provenance record instead of probing the binary.
|
|
4158
|
+
venv_path = find_workspace_venv(workspace_root, sdk_root)
|
|
4159
|
+
venv_record: WorkspaceSdkRecord | None = None
|
|
4160
|
+
if venv_path is not None:
|
|
4161
|
+
record_text = _read_text(venv_path.parent / ".west" / "tan-workspace-sdk")
|
|
4162
|
+
if record_text is not None:
|
|
4163
|
+
venv_record = parse_workspace_sdk_record(record_text)
|
|
4164
|
+
provenance_check = venv_provenance_check(venv_record, sdk_root)
|
|
4165
|
+
if provenance_check is not None:
|
|
4166
|
+
_add(provenance_check)
|
|
4167
|
+
|
|
4168
|
+
if workspace_path is not None:
|
|
4169
|
+
workspace_version = None
|
|
4170
|
+
version_body = _read_text(workspace_path / "zephyr" / "VERSION")
|
|
4171
|
+
if version_body is not None:
|
|
4172
|
+
workspace_version = parse_zephyr_version_file(version_body)
|
|
4173
|
+
sdk_pin_for_workspace = None
|
|
4174
|
+
if sdk_root is not None:
|
|
4175
|
+
west_yml_body = _read_text(Path(sdk_root) / "west.yml")
|
|
4176
|
+
if west_yml_body is not None:
|
|
4177
|
+
sdk_pin_for_workspace = parse_west_zephyr_pin(west_yml_body)
|
|
4178
|
+
zephyr_version_check = zephyr_version_preflight_check(
|
|
4179
|
+
workspace_version, sdk_pin_for_workspace
|
|
4180
|
+
)
|
|
4181
|
+
if zephyr_version_check is not None:
|
|
4182
|
+
_add(zephyr_version_check)
|
|
4183
|
+
# tan-cli#290: unconditional now, sourced from these SAME resolved
|
|
4184
|
+
# facts -- see `zephyr_workspace_check`'s docstring for why it still
|
|
4185
|
+
# earns its own check beside `zephyrVersion` rather than being
|
|
4186
|
+
# dropped as a duplicate.
|
|
4187
|
+
_add(zephyr_workspace_check(str(workspace_path), workspace_version))
|
|
4188
|
+
# tan-cli#1376: read-only; never raises, never fails an offline doctor.
|
|
4189
|
+
_add(
|
|
4190
|
+
workspace_patches_check(
|
|
4191
|
+
check_workspace_patches(workspace_path, sdk_root, timeout=30), str(workspace_path)
|
|
4192
|
+
)
|
|
4193
|
+
)
|
|
4194
|
+
zephyr_base_info = zephyr_base_check(os.environ.get("ZEPHYR_BASE"), str(workspace_path))
|
|
4195
|
+
if zephyr_base_info is not None:
|
|
4196
|
+
_add(zephyr_base_info)
|
|
4197
|
+
|
|
4198
|
+
# tan-cli#294 finding 1: host-environment checks -- also unconditional
|
|
4199
|
+
# HOST facts (no board.yaml/workspace/SDK needed). See their docstrings.
|
|
4200
|
+
host_os, host_arch = _host_os_arch_tags()
|
|
4201
|
+
_add(zephyr_sdk_host_check(host_os, host_arch))
|
|
4202
|
+
if os.name == "nt":
|
|
4203
|
+
_add(
|
|
4204
|
+
long_paths_check(_long_paths_enabled(), _git_core_longpaths(_resolve_git_executable()))
|
|
4205
|
+
)
|
|
4206
|
+
_add(home_path_check(os.environ.get("USERPROFILE" if os.name == "nt" else "HOME")))
|
|
4207
|
+
# `tanInstall`: is this running tan behind the source it was installed from?
|
|
4208
|
+
_add(_tan_install_check())
|
|
4209
|
+
|
|
4210
|
+
# tan-cli#441: `bootstrapManifest` + `hostPrerequisites` (and the manifest
|
|
4211
|
+
# load + Python probe `hostPython`/`pythonFloor`/`west` below also need)
|
|
4212
|
+
# are built by the ONE shared `_resolve_prerequisites_environment` --
|
|
4213
|
+
# `host_environment_checks` calls the SAME function for support-bundle's
|
|
4214
|
+
# seam, so the two callers can never build a diverging verdict.
|
|
4215
|
+
#
|
|
4216
|
+
# tan-cli#980 review nit 3, decided rather than restored: because this
|
|
4217
|
+
# call now builds `hostPrerequisites` (PATH walk over `required`, plus
|
|
4218
|
+
# the POSIX `_posix_venv_capable` spawn) EAGERLY as part of the same
|
|
4219
|
+
# shared unit, `bootstrapManifest`/`hostPython`/`pythonFloor` below are
|
|
4220
|
+
# `_add`ed -- and so, in `tan doctor` TEXT mode, printed by `on_check` --
|
|
4221
|
+
# AFTER that PATH walk and spawn finish, where pre-#441 `_collect` built
|
|
4222
|
+
# and `_add`ed each of the three the moment it had the data, before ever
|
|
4223
|
+
# touching PATH or spawning `_posix_venv_capable`. The returned list
|
|
4224
|
+
# order and the JSON envelope are byte-identical either way (verified in
|
|
4225
|
+
# the PR's own equivalence pass) -- only the incremental print TIMING
|
|
4226
|
+
# `on_check` exists for shifts by the wall-clock cost of two spawns this
|
|
4227
|
+
# single caller already pays for regardless of which check the delay is
|
|
4228
|
+
# attributed to. Restoring the old interleaving would mean splitting
|
|
4229
|
+
# `_resolve_prerequisites_environment` back into a pre-PATH-walk half and
|
|
4230
|
+
# a post-PATH-walk half so `_collect` could `_add` between them -- which
|
|
4231
|
+
# reopens the exact two-copies-of-one-decision risk this helper exists to
|
|
4232
|
+
# close, for a few hundred milliseconds of `tan doctor` console timing.
|
|
4233
|
+
# Accepted as-is.
|
|
4234
|
+
prereq_env = _resolve_prerequisites_environment(sdk_root, workspace_path)
|
|
4235
|
+
if prereq_env.bootstrap_manifest_check is not None:
|
|
4236
|
+
_add(prereq_env.bootstrap_manifest_check)
|
|
4237
|
+
facts = prereq_env.facts
|
|
4238
|
+
effective_floor = prereq_env.effective_floor
|
|
4239
|
+
effective_source = prereq_env.effective_source
|
|
4240
|
+
python_found = prereq_env.python_found
|
|
4241
|
+
|
|
4242
|
+
_add(
|
|
4243
|
+
host_python_check(
|
|
4244
|
+
python_found[:2] if python_found else None,
|
|
4245
|
+
effective_floor,
|
|
4246
|
+
effective_source,
|
|
4247
|
+
find_workspace_venv(workspace_root, sdk_root) is not None,
|
|
4248
|
+
list(prereq_env.host_pythons),
|
|
4249
|
+
)
|
|
4250
|
+
)
|
|
4251
|
+
skew = python_floor_skew_check(
|
|
4252
|
+
prereq_env.manifest_floor,
|
|
4253
|
+
effective_floor,
|
|
4254
|
+
effective_source,
|
|
4255
|
+
manifest_is_real=prereq_env.manifest_is_real,
|
|
4256
|
+
)
|
|
4257
|
+
if skew is not None:
|
|
4258
|
+
_add(skew)
|
|
4259
|
+
|
|
4260
|
+
_add(prereq_env.prerequisites_check)
|
|
4261
|
+
|
|
4262
|
+
west_exe = on_path("west")
|
|
4263
|
+
# tan-cli#488 defect 5: probe `west_exe` -- the PATH-RESOLVED absolute
|
|
4264
|
+
# path `on_path` just returned -- never a second, bare-name spawn.
|
|
4265
|
+
# `on_path` deliberately excludes the cwd for exactly this reason (its own
|
|
4266
|
+
# docstring: a project checked out with its own `west.exe` at its root
|
|
4267
|
+
# must not be reported as this host's tooling); a bare `probe(["west",
|
|
4268
|
+
# ...])` re-opens that gap outright -- CreateProcess searches the cwd
|
|
4269
|
+
# before PATH on Windows, and an empty PATH element (a leading/trailing
|
|
4270
|
+
# `:`, common in a hand-edited profile) does the same on POSIX -- so the
|
|
4271
|
+
# reported version could come from a DIFFERENT binary than the one this
|
|
4272
|
+
# check names. Every other probe in `_collect` already spawns the
|
|
4273
|
+
# resolved path (`west_resolved_exe` above, `jlink_exe` below); this is
|
|
4274
|
+
# the one that did not.
|
|
4275
|
+
west_version = _parse_two(probe([west_exe, "--version"]) or "") if west_exe else None
|
|
4276
|
+
# tan-cli#299 second half: feed `west_check` the SAME resolved venv path
|
|
4277
|
+
# `westResolved` above already computed (`resolved_west`) -- never a
|
|
4278
|
+
# second, independent probe -- so "absent from bare PATH, present in the
|
|
4279
|
+
# resolved venv" (the default post-bootstrap state) reports `pass`
|
|
4280
|
+
# instead of a permanent warn. Only passed when it is a real venv
|
|
4281
|
+
# binary (an absolute path); `west_program`'s bare-`"west"` fallback
|
|
4282
|
+
# carries no information `west_exe` above does not already have.
|
|
4283
|
+
#
|
|
4284
|
+
# tan-cli#488 defect 3: `west_resolved_ran` (computed above, the same
|
|
4285
|
+
# `probe_status` result `westResolved` itself was fixed to consult for
|
|
4286
|
+
# defect 1) also passed through -- so a resolved-but-unspawnable west
|
|
4287
|
+
# cannot report `pass` here while `westResolved` reports `fail` for the
|
|
4288
|
+
# identical binary.
|
|
4289
|
+
_add(
|
|
4290
|
+
west_check(
|
|
4291
|
+
west_exe,
|
|
4292
|
+
west_version,
|
|
4293
|
+
_parse_two(str(facts.get("_pipSpec") or "")),
|
|
4294
|
+
resolved_west if os.path.isabs(resolved_west) else None,
|
|
4295
|
+
west_resolved_ran,
|
|
4296
|
+
)
|
|
4297
|
+
)
|
|
4298
|
+
|
|
4299
|
+
# Unconditional -- not gated on `build` or a resolved board.yaml/SDK. See
|
|
4300
|
+
# `zephyr_sdk_check`'s docstring (tan-cli#286).
|
|
4301
|
+
zephyr_sdk_ok = _zephyr_sdk_detected()
|
|
4302
|
+
_add(zephyr_sdk_check(zephyr_sdk_ok, os.environ.get("ZEPHYR_SDK_INSTALL_DIR")))
|
|
4303
|
+
# issue #474 (ADR 0021 Lane 1 P1): stamp-vs-pin, narrower than `zephyrSdk`
|
|
4304
|
+
# above -- see `toolchain_check`'s own docstring for the distinction.
|
|
4305
|
+
_add(toolchain_check(sdk_root))
|
|
4306
|
+
# tan-cli#1192: beside the two toolchain checks because it reads the SAME
|
|
4307
|
+
# resolved SDK install (`_zephyr_sdk_detected_root`) they do -- but it
|
|
4308
|
+
# answers a question neither does: whether Zephyr's diagnostics-only `dtc`
|
|
4309
|
+
# pass will run at all now that `tan bootstrap` acquires the SDK with
|
|
4310
|
+
# `--no-hosttools` (tan-cli#1178) and so ships no `dtc`. NOT added to
|
|
4311
|
+
# `host_environment_checks` below: `tan support-bundle` reports only the
|
|
4312
|
+
# five checks `_HOST_CHECK_ORDER` names, and this is not one of them --
|
|
4313
|
+
# see `devicetree_lint_check`'s docstring for why that is deliberate.
|
|
4314
|
+
_add(devicetree_lint_check(_resolve_dtc()))
|
|
4315
|
+
# tan-cli#736: unconditional on Windows. This used to ride the
|
|
4316
|
+
# `zephyrSdk` Fail (`and not zephyr_sdk_ok`), which silently excused the
|
|
4317
|
+
# host that has an SDK and no 7-Zip -- exactly the host whose next
|
|
4318
|
+
# `west sdk install` dies with `Zephyr SDK setup requires '7z'`. Still a
|
|
4319
|
+
# `warn`, not a `fail`: see `seven_zip_check`'s docstring and
|
|
4320
|
+
# tan-cli#204 for why the SEVERITY is right and only the GATE was wrong.
|
|
4321
|
+
if os.name == "nt":
|
|
4322
|
+
# tan-cli#1523: same predicate `tan bootstrap`'s preflight uses, so
|
|
4323
|
+
# doctor cannot pass a host (unar-only, or 7z.exe only in the cwd)
|
|
4324
|
+
# that bootstrap refuses.
|
|
4325
|
+
from tan.commands.bootstrap_toolchain_fetch import _find_seven_zip
|
|
4326
|
+
|
|
4327
|
+
_add(seven_zip_check(_find_seven_zip() is not None))
|
|
4328
|
+
|
|
4329
|
+
# tan-cli#1323: the precedence `tan flash` applies (`resolve_setools_dir`),
|
|
4330
|
+
# minus the per-run `--setools-dir` flag doctor cannot see: $SETOOLS_DIR,
|
|
4331
|
+
# then the built manifest's flash_args.setools_dir.
|
|
4332
|
+
flash_info = project_flash(board_yaml)
|
|
4333
|
+
setools_dir = os.environ.get("SETOOLS_DIR") or (
|
|
4334
|
+
flash_info.setools_dir if flash_info is not None else None
|
|
4335
|
+
)
|
|
4336
|
+
setools_source = (
|
|
4337
|
+
"$SETOOLS_DIR" if os.environ.get("SETOOLS_DIR") else "the manifest's flash_args.setools_dir"
|
|
4338
|
+
)
|
|
4339
|
+
_add(
|
|
4340
|
+
setools_check(
|
|
4341
|
+
setools_dir,
|
|
4342
|
+
os.environ.get("SE_UART"),
|
|
4343
|
+
sys.platform.startswith("linux"),
|
|
4344
|
+
flash_info.methods if flash_info is not None else None,
|
|
4345
|
+
jlink_available(
|
|
4346
|
+
str(Path(board_yaml).parent) if board_yaml is not None else workspace_root,
|
|
4347
|
+
sdk_root,
|
|
4348
|
+
),
|
|
4349
|
+
setools_source,
|
|
4350
|
+
)
|
|
4351
|
+
)
|
|
4352
|
+
|
|
4353
|
+
jlink_name, jlink_exe = next(
|
|
4354
|
+
(
|
|
4355
|
+
(name, found)
|
|
4356
|
+
for name in ("JLinkExe", "JLink", "JLinkGDBServerCL")
|
|
4357
|
+
if (found := on_path(name))
|
|
4358
|
+
),
|
|
4359
|
+
(None, None),
|
|
4360
|
+
)
|
|
4361
|
+
# JLinkExe has no `-?` flag (`Unknown command line option -?.`); the
|
|
4362
|
+
# version banner it prints unprompted at the top of every real invocation
|
|
4363
|
+
# is what `jlink_banner` reads instead. See its docstring. NOT spawned
|
|
4364
|
+
# against `JLinkGDBServerCL` (the third fallback name): that binary never
|
|
4365
|
+
# reads stdin and never quits on `jlink_banner`'s `exit\n`, so it would
|
|
4366
|
+
# hold a GDB port for the full `PROBE_TIMEOUT_S` on every `tan doctor`
|
|
4367
|
+
# run on a host where only the GDB server is on PATH. `jlink_check`
|
|
4368
|
+
# already reports "found but version could not be read" for a `None`
|
|
4369
|
+
# version, which is the correct outcome here too.
|
|
4370
|
+
jlink_version = (
|
|
4371
|
+
_parse_two(jlink_banner(jlink_exe) or "")
|
|
4372
|
+
if jlink_exe and jlink_name != "JLinkGDBServerCL"
|
|
4373
|
+
else None
|
|
4374
|
+
)
|
|
4375
|
+
resolved_device, device_source = jlink_flash_device(sdk_root)
|
|
4376
|
+
_add(jlink_check(jlink_exe, jlink_version, resolved_device, device_source))
|
|
4377
|
+
|
|
4378
|
+
# tan-cli#294 finding 5: LAST, mirroring `assemble_doctor_report`'s own
|
|
4379
|
+
# placement -- traces a report back to the SDK checkout that produced it.
|
|
4380
|
+
# tan-cli#727: skipped for a `--sdk-root` the marker check rejected.
|
|
4381
|
+
# `sdk_provenance_check` finds no git checkout and no
|
|
4382
|
+
# `metadata/sdk_version.yaml` under a path that is not there, and its
|
|
4383
|
+
# else-branch renders that absence as `pass | alp-sdk at <path> (no git
|
|
4384
|
+
# checkout / metadata/sdk_version.yaml)` -- a green line asserting an
|
|
4385
|
+
# alp-sdk at a directory that does not exist.
|
|
4386
|
+
if sdk_root is not None and dangling_flag_root is None:
|
|
4387
|
+
_add(sdk_provenance_check(sdk_root))
|
|
4388
|
+
|
|
4389
|
+
return checks
|
|
4390
|
+
|
|
4391
|
+
|
|
4392
|
+
def _generated_at() -> str:
|
|
4393
|
+
"""`SOURCE_DATE_EPOCH` when set, so a captured envelope is reproducible --
|
|
4394
|
+
`tan.core.timestamp`, which NEVER raises.
|
|
4395
|
+
|
|
4396
|
+
An out-of-range epoch (the MILLISECONDS case) used to throw from here, and
|
|
4397
|
+
the caller's own try/except then reported `doctor.internal-failure`: a
|
|
4398
|
+
fabricated "tan is broken" verdict on a host that was diagnosed fine.
|
|
4399
|
+
"""
|
|
4400
|
+
return generated_at_iso()
|
|
4401
|
+
|
|
4402
|
+
|
|
4403
|
+
def _print_stream_lines(lines: Iterable[str], label: str) -> None:
|
|
4404
|
+
"""The ONE guarded stderr-print path `doctor()`'s two streaming call
|
|
4405
|
+
sites both go through -- once per finished check (`_print_check`) and
|
|
4406
|
+
once more for the footer (summary / failed-checks / extra-issue lines)
|
|
4407
|
+
-- neither prints via a bare `print()` loop of its own any more.
|
|
4408
|
+
|
|
4409
|
+
Measured, not inferred (this repo's own oracle rule -- established by
|
|
4410
|
+
RUNNING, never by reasoning about it): `tan.cli.main` calls
|
|
4411
|
+
`_reconfigure_stdio()` before any command runs, which puts `sys.stderr`
|
|
4412
|
+
in utf-8/`errors="strict"` -- a legacy-codepage stream (`cp1252`) never
|
|
4413
|
+
reaches this function on the real CLI path, and `cp1252` can encode `…`
|
|
4414
|
+
(U+2026) anyway (`"\\u2026".encode("cp1252")` == `b"\\x85"`), so that was
|
|
4415
|
+
never the actual risk. What DOES raise `UnicodeEncodeError` on a strict
|
|
4416
|
+
utf-8 stream is a lone surrogate (e.g. `\\udcff`) -- a filesystem path
|
|
4417
|
+
that reached Python via `surrogateescape` (`on_path`'s `shutil.which`
|
|
4418
|
+
result, resolved off `PATH`) and that a check's `detail`/`fix` text goes
|
|
4419
|
+
on to print verbatim; utf-8 cannot encode a lone surrogate no matter what
|
|
4420
|
+
decoded it. `OSError` (its subclass `BrokenPipeError` in particular)
|
|
4421
|
+
covers a closed PIPE (`tan doctor | head`); `ValueError` covers a stream
|
|
4422
|
+
closed OUTRIGHT (`I/O operation on closed file` -- the object itself is
|
|
4423
|
+
gone, not merely refusing writes at the far end) -- included
|
|
4424
|
+
deliberately, because the one thing this guard exists to prevent,
|
|
4425
|
+
discarding an already-finished diagnosis over how the stream died, does
|
|
4426
|
+
not care which of the three killed it, and the `try` body here is
|
|
4427
|
+
nothing but `print()` calls to a fixed stream, so a bare `ValueError`
|
|
4428
|
+
catch has no wider blast radius to worry about.
|
|
4429
|
+
|
|
4430
|
+
Unguarded, any of the three propagates out of `_collect` (as `on_check`)
|
|
4431
|
+
into `doctor()`'s own `except Exception`, discarding every check this
|
|
4432
|
+
run already completed as a fabricated `doctor.internal-failure`; from
|
|
4433
|
+
the footer call site (never inside that `try`) it would instead escape
|
|
4434
|
+
`doctor()` outright as a raw, unhandled traceback. Caught here instead,
|
|
4435
|
+
and never silently: a second, INDEPENDENT `try` wraps the failure note
|
|
4436
|
+
itself, since it prints through the SAME `sys.stderr` that just failed.
|
|
4437
|
+
Nothing left to do if even that fails -- the diagnosis behind it already
|
|
4438
|
+
made it back to `doctor()` (or, for the footer, was already fully
|
|
4439
|
+
rendered) regardless.
|
|
4440
|
+
"""
|
|
4441
|
+
try:
|
|
4442
|
+
for line in lines:
|
|
4443
|
+
print(line, file=sys.stderr)
|
|
4444
|
+
except (UnicodeError, OSError, ValueError) as err:
|
|
4445
|
+
try:
|
|
4446
|
+
print(
|
|
4447
|
+
f"doctor: could not print the '{label}' block "
|
|
4448
|
+
f"({type(err).__name__}: {err}).",
|
|
4449
|
+
file=sys.stderr,
|
|
4450
|
+
)
|
|
4451
|
+
except (UnicodeError, OSError, ValueError):
|
|
4452
|
+
pass
|
|
4453
|
+
|
|
4454
|
+
|
|
4455
|
+
def doctor(
|
|
4456
|
+
project: str = typer.Option(
|
|
4457
|
+
None, "--project", metavar="PATH", help="Project root (defaults to '.')."
|
|
4458
|
+
),
|
|
4459
|
+
sdk_root: str = typer.Option(
|
|
4460
|
+
None, "--sdk-root", metavar="PATH", help="alp-sdk checkout root."
|
|
4461
|
+
),
|
|
4462
|
+
board_yaml: str = typer.Option(
|
|
4463
|
+
None, "--board-yaml", metavar="PATH", help="Explicit board.yaml path."
|
|
4464
|
+
),
|
|
4465
|
+
build: bool = typer.Option(
|
|
4466
|
+
False,
|
|
4467
|
+
"--build",
|
|
4468
|
+
help=inert_help(
|
|
4469
|
+
"Accepted for compatibility: zephyrWorkspace, the check this used "
|
|
4470
|
+
"to gate, now runs unconditionally, so this flag no longer changes "
|
|
4471
|
+
"the check list.",
|
|
4472
|
+
COMPATIBILITY,
|
|
4473
|
+
"tan-cli#290",
|
|
4474
|
+
),
|
|
4475
|
+
),
|
|
4476
|
+
fix: bool = typer.Option(
|
|
4477
|
+
False,
|
|
4478
|
+
"--fix",
|
|
4479
|
+
help="Run the manifest's own install command (ADR 0021) for any "
|
|
4480
|
+
"hostPrerequisites tool this host is missing. A command needing no "
|
|
4481
|
+
"elevation runs for any caller; a `sudo`-prefixed one runs too, but "
|
|
4482
|
+
"only when this process is already root, with the literal `sudo` "
|
|
4483
|
+
"word stripped first -- tan itself never spawns the `sudo` program. "
|
|
4484
|
+
"A non-root caller instead gets that command printed to run by hand. "
|
|
4485
|
+
"Only in an interactive, non-CI, text-mode run (a real terminal on "
|
|
4486
|
+
"both stdin and stderr; --non-interactive/--ci/--format json all "
|
|
4487
|
+
"disable it): a repair a human did not watch happen is not consent.",
|
|
4488
|
+
),
|
|
4489
|
+
output_format: OutputFormat = typer.Option(OutputFormat.TEXT, "--format", help=FORMAT_HELP),
|
|
4490
|
+
non_interactive: bool = typer.Option(
|
|
4491
|
+
False,
|
|
4492
|
+
"--non-interactive",
|
|
4493
|
+
help="Never prompt, and never run --fix's repairs -- see --fix.",
|
|
4494
|
+
),
|
|
4495
|
+
ci: bool = typer.Option(
|
|
4496
|
+
False, "--ci", help="CI mode: implies --non-interactive and disables --fix."
|
|
4497
|
+
),
|
|
4498
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colour."),
|
|
4499
|
+
) -> None:
|
|
4500
|
+
"""Diagnose whether this host can build and flash."""
|
|
4501
|
+
json_mode = output_format == "json"
|
|
4502
|
+
|
|
4503
|
+
# Snapshot the RAW `--project` value before `project` is reassigned below
|
|
4504
|
+
# to the envelope's `Project` object -- `sdk_check`'s scoped-switch hint
|
|
4505
|
+
# (tan-cli#294 finding 2 / #101) needs the string, not the envelope block.
|
|
4506
|
+
project_scope = project
|
|
4507
|
+
|
|
4508
|
+
# tan-cli#488 defect 8: safe defaults for every name the exception
|
|
4509
|
+
# handler below and the final `emit()` read, so a raise from ANYWHERE
|
|
4510
|
+
# in this prologue -- not only inside `_collect` -- still produces the
|
|
4511
|
+
# `doctor.internal-failure` envelope the guard below promises, instead
|
|
4512
|
+
# of a raw traceback with empty stdout. `Path.cwd()` a few lines below
|
|
4513
|
+
# is the concrete case: a working directory deleted out from under the
|
|
4514
|
+
# process (a cleanup script racing a `tan doctor` run in the same
|
|
4515
|
+
# shell) throws `FileNotFoundError` -- previously from OUTSIDE the
|
|
4516
|
+
# `try`/`except`, before `project`/`sdk`/`sdk_tier` had a real value,
|
|
4517
|
+
# so it unwound through typer's own traceback renderer instead of this
|
|
4518
|
+
# command's error contract. Every value here is overwritten the moment
|
|
4519
|
+
# the try body below reaches its own assignment.
|
|
4520
|
+
project_envelope: Project = Project(root=None, board_yaml=None)
|
|
4521
|
+
sdk: SdkInfo | None = None
|
|
4522
|
+
sdk_tier = "none"
|
|
4523
|
+
sdk_broken_pin: str | None = None
|
|
4524
|
+
sdk_foreign_default: str | None = None
|
|
4525
|
+
try:
|
|
4526
|
+
# `util::cli_workspace_root`: `--project` joined onto the cwd, and
|
|
4527
|
+
# everything below (board.yaml discovery, SDK discovery, the reported
|
|
4528
|
+
# `project.root`) anchors on THAT -- see `build_cmd.build` for the same
|
|
4529
|
+
# pattern and why an unanchored `--project` builds the wrong project.
|
|
4530
|
+
cwd = Path.cwd()
|
|
4531
|
+
workspace_root = cwd if project is None else Path(os.path.join(str(cwd), project))
|
|
4532
|
+
|
|
4533
|
+
# Anchor an EXPLICIT `--board-yaml` on `workspace_root`, not the real cwd,
|
|
4534
|
+
# BEFORE the discovery branch below -- same pattern as `build_cmd.build`
|
|
4535
|
+
# and `crates/tan-core/src/project.rs:198-208`'s `resolve_board_yaml_path`.
|
|
4536
|
+
# Left unanchored, a relative `--board-yaml` under `--project app` reports
|
|
4537
|
+
# (and would build/flash) the board.yaml sitting in the real cwd instead
|
|
4538
|
+
# of the one inside `app`.
|
|
4539
|
+
if board_yaml is not None and not os.path.isabs(board_yaml):
|
|
4540
|
+
board_yaml = os.path.join(str(workspace_root), board_yaml)
|
|
4541
|
+
if board_yaml is None and (workspace_root / "board.yaml").is_file():
|
|
4542
|
+
board_yaml = str(workspace_root / "board.yaml")
|
|
4543
|
+
# `--sdk-root` > `.alp/sdk-path` project pin > machine-global default >
|
|
4544
|
+
# the positional walk (`resolve_sdk_root_ladder`) -- no `ALP_SDK_ROOT`
|
|
4545
|
+
# tier (tried and reverted -- see `resolve_sdk_root_ladder`'s own
|
|
4546
|
+
# docstring). Previously this skipped straight from `--sdk-root` to the
|
|
4547
|
+
# positional walk, silently ignoring `tan init`'s own pointer in the same
|
|
4548
|
+
# directory.
|
|
4549
|
+
sdk_resolution = resolve_sdk_root_ladder(sdk_root, workspace_root)
|
|
4550
|
+
resolved_sdk_root = sdk_resolution.path
|
|
4551
|
+
sdk_tier = sdk_resolution.tier
|
|
4552
|
+
sdk_broken_pin = sdk_resolution.broken_project_pin
|
|
4553
|
+
sdk_foreign_default = sdk_resolution.foreign_global_default_for
|
|
4554
|
+
sdk_root = str(resolved_sdk_root) if resolved_sdk_root is not None else None
|
|
4555
|
+
sdk = SdkInfo.from_resolution(sdk_root, sdk_resolution) if sdk_root is not None else None
|
|
4556
|
+
# tan-cli#344: a dangling `~/.alp/sdk-default` is a distinct fact from
|
|
4557
|
+
# "nothing configured" -- computed unconditionally (one small file read)
|
|
4558
|
+
# so `sdk_check` can name it in the one branch (`sdk_root is None`) where
|
|
4559
|
+
# the two used to print the identical sentence.
|
|
4560
|
+
broken_global_default = _broken_global_default()
|
|
4561
|
+
# Forward slashes -- the established envelope contract on this seam
|
|
4562
|
+
# (`build_cmd.build`, `flash_cmd._resolve_project`), not the native
|
|
4563
|
+
# separators `str(Path(...))` would emit on Windows.
|
|
4564
|
+
#
|
|
4565
|
+
# tan-cli#236: `boardYaml` reported only when the file really exists. An
|
|
4566
|
+
# explicit `--board-yaml` skips the `is_file()` discovery guard above, so
|
|
4567
|
+
# without this it could still name a path nothing sits at.
|
|
4568
|
+
project_envelope = Project.resolved(
|
|
4569
|
+
_abs_posix(str(workspace_root)),
|
|
4570
|
+
_abs_posix(board_yaml) if board_yaml is not None else None,
|
|
4571
|
+
)
|
|
4572
|
+
|
|
4573
|
+
# Text mode streams each check the MOMENT it completes (tan-cli doctor
|
|
4574
|
+
# v2, change 3): `_collect` runs up to 15 subprocess probes at
|
|
4575
|
+
# `PROBE_TIMEOUT_S` each, and the old code printed nothing until every
|
|
4576
|
+
# one of them had answered -- on a host where one wedges, a blank
|
|
4577
|
+
# terminal names nothing. Width floored at `TEXT_WRAP_MIN_WIDTH` -- a
|
|
4578
|
+
# 20-column terminal must not fall to one word per line -- and measured
|
|
4579
|
+
# through `tan.env.terminal_width`, the one place that reads a
|
|
4580
|
+
# terminal's columns, sharing the 100-column fallback with
|
|
4581
|
+
# `tan.env.wrap_width`. tan-cli#564: this used to be a bare
|
|
4582
|
+
# `shutil.get_terminal_size(fallback=(100, 24))`, which measures
|
|
4583
|
+
# `sys.__stdout__` -- but this report is printed to STDERR, so
|
|
4584
|
+
# `tan doctor > report.txt` from a 70-column terminal took the 100 and
|
|
4585
|
+
# put 94-, 95- and 105-column lines on a 70-column screen.
|
|
4586
|
+
# `width`/`color` are resolved unconditionally, even under
|
|
4587
|
+
# `--format json` where neither is ever read: both calls are cheap and
|
|
4588
|
+
# emit nothing, so gating them behind `if stream` bought nothing but a
|
|
4589
|
+
# second, held-together-by-an-11-line-comment invariant (`_print_check`
|
|
4590
|
+
# closes over both) for no benefit.
|
|
4591
|
+
#
|
|
4592
|
+
# UNCONDITIONAL on stderr being a terminal at all (unlike
|
|
4593
|
+
# `tan.env.wrap_width`, which `explain`/`sdk current` use instead): a
|
|
4594
|
+
# piped `tan doctor` still wraps to `terminal_width`'s own fallback
|
|
4595
|
+
# today. That is a real inconsistency with the new seam, left exactly as
|
|
4596
|
+
# it shipped in PR #480 rather than silently fixed here -- see that PR
|
|
4597
|
+
# for the call to change it.
|
|
4598
|
+
stream = not json_mode
|
|
4599
|
+
width = max(terminal_width(100), TEXT_WRAP_MIN_WIDTH)
|
|
4600
|
+
color = use_color(no_color, ci)
|
|
4601
|
+
|
|
4602
|
+
def _print_check(check: Check) -> None:
|
|
4603
|
+
"""`on_check`'s real, streaming callback: render one check's block
|
|
4604
|
+
and print it through `_print_stream_lines` -- the FOURTH way
|
|
4605
|
+
`_collect` reaches the outside world (see its docstring), and the
|
|
4606
|
+
only one of the four that can raise. See `_print_stream_lines`'s own
|
|
4607
|
+
docstring for the guard and why it is shaped the way it is; not
|
|
4608
|
+
restated here.
|
|
4609
|
+
"""
|
|
4610
|
+
_print_stream_lines(render_check_lines(check.as_dict(), width, color), check.name)
|
|
4611
|
+
|
|
4612
|
+
checks = _collect(
|
|
4613
|
+
sdk_root,
|
|
4614
|
+
build=build,
|
|
4615
|
+
board_yaml=board_yaml,
|
|
4616
|
+
project_scope=project_scope,
|
|
4617
|
+
workspace_root=str(workspace_root),
|
|
4618
|
+
sdk_tier=sdk_tier,
|
|
4619
|
+
broken_global_default=broken_global_default,
|
|
4620
|
+
on_check=_print_check if stream else None,
|
|
4621
|
+
)
|
|
4622
|
+
# tan-cli#91 / ADR 0021: `--fix` only ever RUNS anything when a human
|
|
4623
|
+
# is demonstrably present. `doctor` otherwise only REPORTS; this flag
|
|
4624
|
+
# turns it into a machine-global, network-fetching installer, so the
|
|
4625
|
+
# consent gate is the feature, not decoration around it.
|
|
4626
|
+
#
|
|
4627
|
+
# Delegated to [`tan.core.consent.can_prompt`] rather than spelled out
|
|
4628
|
+
# inline, because spelling it out inline is exactly how this went
|
|
4629
|
+
# wrong: the hand-written form tested only the three FLAGS
|
|
4630
|
+
# (`!non_interactive && !ci && !is_json`) and omitted the two
|
|
4631
|
+
# `isatty()` calls, so a CI runner that redirected its output but did
|
|
4632
|
+
# not happen to pass `--ci` got unattended host mutation -- measured
|
|
4633
|
+
# under fully captured pipes, four real `winget install` runs with
|
|
4634
|
+
# nobody watching. The oracle's own `--non-interactive` help states
|
|
4635
|
+
# the missing half ("the same rule applies unasked when stdin or
|
|
4636
|
+
# stderr is not a terminal -- piped, redirected, or a CI runner").
|
|
4637
|
+
# See that module for why BOTH handles matter, and why `stdout`
|
|
4638
|
+
# deliberately does not.
|
|
4639
|
+
fix_allowed = fix and can_prompt(non_interactive=non_interactive, ci=ci, json_mode=json_mode)
|
|
4640
|
+
if fix_allowed:
|
|
4641
|
+
# `fix_missing`, NEVER `missing`: tan-cli#760 review MAJOR 1.
|
|
4642
|
+
# `missing` is the CONFIRMED, customer-facing field (`data.
|
|
4643
|
+
# missingPrerequisites`) and nulls a command whose own installer
|
|
4644
|
+
# this host cannot confirm; `run_fix` needs the RAW commands to
|
|
4645
|
+
# do its OWN on_path resolution and keep reporting tan-cli#360's
|
|
4646
|
+
# `doctor.fix-installer-not-found` diagnosis on the hosts #360
|
|
4647
|
+
# exists for, instead of a silent no-op.
|
|
4648
|
+
missing_for_fix = next(
|
|
4649
|
+
(c.fix_missing for c in checks if c.name == "hostPrerequisites"), None
|
|
4650
|
+
)
|
|
4651
|
+
if missing_for_fix:
|
|
4652
|
+
# These checks stream too, same as every check `_collect`
|
|
4653
|
+
# produced above -- `run_fix` takes the SAME `on_check`
|
|
4654
|
+
# `_collect` does, gated on `stream` directly (never on
|
|
4655
|
+
# "`fix_allowed` already implies `not json_mode`": that
|
|
4656
|
+
# inference only holds for the REAL `can_prompt`, and a test
|
|
4657
|
+
# double stubbing `can_prompt` can make `fix_allowed` true
|
|
4658
|
+
# under `--format json`, where printing a check block to
|
|
4659
|
+
# stderr would be wrong regardless of `width`/`color` being
|
|
4660
|
+
# bound -- both are resolved unconditionally now, see above).
|
|
4661
|
+
fix_checks = run_fix(missing_for_fix, on_check=_print_check if stream else None)
|
|
4662
|
+
checks = [*checks, *fix_checks]
|
|
4663
|
+
exit_code = exit_code_for(checks)
|
|
4664
|
+
issues = checks_to_issues(checks)
|
|
4665
|
+
# tan-cli#91 P1: `--fix` requested and consent refused used to be a
|
|
4666
|
+
# SILENT no-op, byte-for-byte identical to plain `tan doctor` --
|
|
4667
|
+
# measured against the oracle (`doctor --fix --format json`, which the
|
|
4668
|
+
# oracle instead refuses to parse outright). SAY SO instead: name
|
|
4669
|
+
# every condition of `can_prompt`'s that actually tripped.
|
|
4670
|
+
if fix and not fix_allowed:
|
|
4671
|
+
issues = [
|
|
4672
|
+
*issues,
|
|
4673
|
+
fix_suppressed_issue(non_interactive=non_interactive, ci=ci, json_mode=json_mode),
|
|
4674
|
+
]
|
|
4675
|
+
# tan-cli#294 finding 4 (#203/#210, alp-sdk-vscode#347, ADR 0021 P0a):
|
|
4676
|
+
# `hostPrerequisites` is the only check that ever carries a
|
|
4677
|
+
# `{tool, command}` pair, so it is the only place this reads from --
|
|
4678
|
+
# mirrors `apply_prerequisite_check`'s report-level field. `alp-sdk-
|
|
4679
|
+
# vscode`'s `runDependencyAction` sends `missingPrerequisites[].command`
|
|
4680
|
+
# to a terminal; without this key that one-click affordance silently
|
|
4681
|
+
# disappears on the extension side (the extension itself does not
|
|
4682
|
+
# crash on absence -- it feature-detects on the key, per
|
|
4683
|
+
# `vscodeAdapter.ts`).
|
|
4684
|
+
missing_prerequisites = next(
|
|
4685
|
+
(c.missing for c in checks if c.name == "hostPrerequisites"), None
|
|
4686
|
+
)
|
|
4687
|
+
data = {
|
|
4688
|
+
"generatedAt": _generated_at(),
|
|
4689
|
+
"summary": summarise(checks),
|
|
4690
|
+
"checks": [c.as_dict() for c in checks],
|
|
4691
|
+
"nextSteps": next_steps(checks),
|
|
4692
|
+
"missingPrerequisites": missing_prerequisites,
|
|
4693
|
+
}
|
|
4694
|
+
except Exception as err: # noqa: BLE001
|
|
4695
|
+
# The port's most-repeated defect class: an uncaught exception escapes as
|
|
4696
|
+
# a raw traceback, stdout stays empty, and the extension renders nothing
|
|
4697
|
+
# with no error on either side. Every probe above is already guarded, and
|
|
4698
|
+
# so is the one thing `_collect` calls that is not a probe: its `on_check`
|
|
4699
|
+
# callback prints through `_print_stream_lines`, which guards its own
|
|
4700
|
+
# failures (see its docstring) instead of letting them unwind here and
|
|
4701
|
+
# discard a diagnosis that already finished -- so anything still reaching
|
|
4702
|
+
# here is a tan bug -- reported as one, with an envelope. INTERNAL_FAILURE,
|
|
4703
|
+
# not DOCTOR_FAILURE: the host was never diagnosed, and claiming it is
|
|
4704
|
+
# unhealthy would be a fabricated verdict.
|
|
4705
|
+
exit_code = ExitCode.INTERNAL_FAILURE
|
|
4706
|
+
data = None
|
|
4707
|
+
issues = [Issue("doctor.internal-failure", "error", f"{type(err).__name__}: {err}")]
|
|
4708
|
+
|
|
4709
|
+
# tan-cli#263 review: this is the "tan doctor says ready, 0 issues"
|
|
4710
|
+
# report -- a `.alp/sdk-path` pin that silently missed must show up here,
|
|
4711
|
+
# not just on a `sdk current` a suspicious operator has to think to run.
|
|
4712
|
+
pin_issue = project_pin_issue(sdk_broken_pin, sdk_tier)
|
|
4713
|
+
if pin_issue is not None:
|
|
4714
|
+
issues = [pin_issue, *issues]
|
|
4715
|
+
foreign_issue = global_default_foreign_project_issue(sdk_foreign_default)
|
|
4716
|
+
if foreign_issue is not None:
|
|
4717
|
+
issues = [foreign_issue, *issues]
|
|
4718
|
+
|
|
4719
|
+
if json_mode:
|
|
4720
|
+
emit(Envelope("doctor", project_envelope, data, issues, exit_code, sdk=sdk))
|
|
4721
|
+
elif data is None:
|
|
4722
|
+
for issue in issues:
|
|
4723
|
+
print(f"{issue.severity}: {issue.message}", file=sys.stderr)
|
|
4724
|
+
else:
|
|
4725
|
+
# tan-cli#375: an Issue with no backing Check -- a suppressed `--fix`
|
|
4726
|
+
# (`fix_suppressed_issue`) or a broken `.alp/sdk-path` pin
|
|
4727
|
+
# (`project_pin_issue`, tan-cli#263) -- used to vanish here
|
|
4728
|
+
# completely: this branch (the non-exception path) used to print
|
|
4729
|
+
# only the per-check lines and the summary, never `issues`. `--fix`'s
|
|
4730
|
+
# consent gate (tan-cli#91) is right to suppress silently TOWARDS THE
|
|
4731
|
+
# HOST -- it must never mutate anything unwatched -- but silence
|
|
4732
|
+
# towards the CUSTOMER is a different bug: the JSON envelope already
|
|
4733
|
+
# carried `doctor.fix-suppressed`/`sdk.project-pin-unresolved` and
|
|
4734
|
+
# text mode carried neither. `checks_to_issues(checks)` already turns
|
|
4735
|
+
# every warn/fail Check into an Issue, and each of those already
|
|
4736
|
+
# rendered as its own `[ warn]`/`[ fail]` block, streamed above as
|
|
4737
|
+
# `_collect` produced it -- reprinting them here would duplicate the
|
|
4738
|
+
# whole report, so this filters down to exactly the codes no Check
|
|
4739
|
+
# block already named. `render_doctor_footer` places these AFTER
|
|
4740
|
+
# every check block (already on screen) and BEFORE the summary:
|
|
4741
|
+
# findable without being buried mid-report, report still ends on the
|
|
4742
|
+
# summary line.
|
|
4743
|
+
# `kebab_check_name`, not the bare `c.name`: tan-cli#461 made the
|
|
4744
|
+
# camelCase-to-kebab conversion the one shared way a `Check.name`
|
|
4745
|
+
# becomes an issue-code suffix, so the set built here must be spelled
|
|
4746
|
+
# the same way `checks_to_issues` spells the codes it is filtering
|
|
4747
|
+
# against -- otherwise every check-backed issue misses the set and
|
|
4748
|
+
# the whole report prints twice.
|
|
4749
|
+
checked_codes = {c.code or f"doctor.{kebab_check_name(c.name)}" for c in checks}
|
|
4750
|
+
extra_issue_lines = [
|
|
4751
|
+
f"{issue.severity}: {issue.message}"
|
|
4752
|
+
for issue in issues
|
|
4753
|
+
if issue.code not in checked_codes
|
|
4754
|
+
]
|
|
4755
|
+
_print_stream_lines(
|
|
4756
|
+
render_doctor_footer(data["checks"], data["summary"], extra_issue_lines, width),
|
|
4757
|
+
"footer",
|
|
4758
|
+
)
|
|
4759
|
+
|
|
4760
|
+
raise typer.Exit(int(exit_code))
|
|
4761
|
+
|
|
4762
|
+
|
|
4763
|
+
# tan-cli#261: adds the four oracle `GlobalArgs` flags this command was still
|
|
4764
|
+
# missing (`--all`/`--quiet`/`--target`/`--verbose`) on top of `--no-color`/
|
|
4765
|
+
# `--non-interactive`/`--ci`, already declared and wired into `can_prompt`
|
|
4766
|
+
# above; see `tan.core.global_flags`.
|
|
4767
|
+
doctor = accept_global_flags(doctor)
|